Часть 3: разбор и сборка
В предыдущей части мы собрали себе «верстак» для написания приложения для MirageOS. Поставили и настроили OCaml и пакет mirage. Теперь приступим к собственно приложению. Наперёд замечу, что объяснять тонкости и толстости OCaml-я — это отдельная сказка, и в этом посте её будет довольно мало. Сосредоточимся на нашей игрушке, миражном хеллоуворлде: я буду пояснять только некоторые интересные моменты по приложению. Если интересно начать штурмовать порог входа в язык, добро пожаловать на сайт Книжки [спойлер: там есть устаревшие моменты] и в коллекцию всевозможной документации, которая в OCaml-сообществе традиционно довольно хороша. Хороша настолько, что в середине нулевых мне хватило недели стандартного мануала языка, чтобы задеплоить первый работающий код. А я не гений программирования, от слова «совсем».
Сегодня мы пощупаем наше учебное приложение.
Клоним https://github.com/argent-smith/mirage-hello.git. Наблюдаем, что там «не густо»:
Схема минимального приложения такова:
.merlin
— файл настроек для мерлина (см. в пред. части). Там написано, какие пакеты мерлин будет использовать при ресолве типов для показательства красивостей и прогноза ошибок. Строго говоря, этот файл не обязателен: мы в юниксе, и девелопить тут можно прям в каком-нибудь Ed[itor] (древний построчный редактор, намучившись с которым люди создали шиииикарный Vi[sual]). Однако с практической точки зрения.merlin
принято держать в репе, так как он избавляет товарищей по разработке от головных болей и к тому же позволяет быстро вспомнить, какие пакеты/библиотеки используются в проекте.config.ml
— это уже исходник проекта, но своеобразный. Он описывает конфигурацию проекта для код-генератора фреймворка. Посмотрим-ка внутрь:
- Генератор будет компилировать этот файл как вполне себе программу, поэтому мы видим тут и импорт мета-модуля фреймворка —
open Mirage
, и функциюmain
, которая, несмотря на название по соглашению, не является энтри-пойнтом, и настоящий энтри-пойнтlet () = …
. Лирическое отступление: OCaml признан одним из лучших языков в плане поддержки раздельной компиляции. Программа всегда строится из модулей. Каждый файл исходника будет скомпилирован в отдельный бинарный модуль, затем линкер свяжет модули в результирующий бинарник. Эта система как бы намекает разработчику, что файл исходника должен содержать некоторый законченный смысл: множество объявлений (у нас декларативный язык, упс) типов, допущенных к ним функций и межмодульных связей-вызовов. Далее, OCaml — это два языка в одном: собственно язык программирования и язык метапрограммирования/модулей. Кусочки этого метаязыка мы видим и в нашем файле: мы «открываем» мета-модуль (модуль модулей) фреймворка, затем в вызове тамошней функцииforeign
определяем список используемых пакетов и модуль, где искать собственно программу —Unikernel.Hello
, которая нуждается в компонентеtime
и образует Задачуjob
; в конце концов, регистрируем модуль, добытый из всей этой информации —register …
unikernel.ml
— собственно, «мясо» нашего приложения. Название файла конвенционально, главное, чтобы оно совпадало с названием модуля, указываемого в вызовеforeign
вconfig.ml
. Принято, что вunikernel.ml
всегда лежит точка входа в приложение. Давайте пробежим файлик «по диагонали»:
Мы объявляем функтор Hello
, получающий в качестве параметра модуль Time, соответствующий сигнатуре Mirage_time_lwt.S
. Затем объявляем функцию start
с неиспользуемым параметром _time
(побочка от обязательного соответствия стандарту: в параметры start
могут попадать и вполне полезные вещи, такие как объекты разных девайсов и т.п.). Внутри start
объявляется локальная рекурсивная функция loop
, которая вызывается в конце start
… И вот тут надо кое-что объяснить.
Дело в том, что все выражения (кроме арифметических), которые мы видим ниже loop
(да и start
, подозреваю, тоже) имеют тип … -> a' Lwt.t
, то есть являются lightweight threads. Например, Lwt.return_unit
— это тред, который немедленно возвращает «пустышку» типа unit (это точка выхода из нашей хвосторекурсивной «петли»), Time.sleep_ns
— тред, спящий нужное количество наносекунд, следом за которым стартует … тред loop
— и поскольку в OCaml компилятор преобразует хвостатую рекурсию в итерацию на машинном уровне, мы не точим стеки и кучу, а спокойно тысячу раз выполняем распечатку тредом Logs.info
нужного нам сообщения, которое завёрнуто (привет, js-ники) в вызов некоторого подобия коллбэка — треда форматтера сообщения.
Код написан в доввольно-таки линейном функциональном стиле, но за кулисами тут работает библиотека Lwt, которая где-следует запускает ивент-луп, шедулер и диспетчеризует наши треды. Шедулинг случается в каждой точке вызова следующего треда (функции в тип a' Lwt.t
), поэтому если код написан корректно, а IO выполняется Lwt-специфическими аналогами stdlib-функций, то всё шустрит и не виснет вполне себе мультипоточно. Если усложнить наш пример и использовать примитивы размножения/собирания тредов, это станет видно. Но пока у нас нет задачи за один абзац изучить Lwt, и мы ограничимся «одинарным» мультитредом. К слову, у миража есть превосходный туториал про Lwt, который в коктейле с вышеупомянутым мануалом даёт довольно быстрый удар в голову по означенной тематике.
Ещё следует для удовлетворения ЧСВ FP-нубов вроде меня заметить, что в приведённом коде наблюдается самая что ни на есть монада в синтаксическом украшении >>
, но мы об этом никому не скажем, чтобы не распугать тех, кого в детстве напугали матаном, а скажем, что это в ML-языках такой специальный DSL для описания явно объявляемых последовательностей действий)
Итак, у нас теперь есть какое-никакое представление о том, чего мы хотим от нашего юникернела (тысячу раз напечатать в консоль красивый лог про «hello» и завершиться). Попробуем собрать.
Раз. Запускаем генератор-конфигуратор:
Он скушает наш config.ml
и создаст необходимые исходники/makefiles и т.п. под целевое окружение проекта. По дефолту целью является юникс-бинарник под текущую ось разработчика. Что там у нас в директории?
Файлов заметно прибавилось, но они платформенно-специфические, поэтому в репе мы их сохранять не будем — каждый раз mirage configure
их будет переписывать.
Два. Устаканиваем зависимости для последующей сборки:
Три. Запускаем сборку:
Упс! Депенденси поставились не все. Обидно, досадно, ну ладно. Отправлю репорт в мираж-тим. А пока доставлю ручками всё, чего не хватает:
Ну вот. Теперь make:
С трепетом запускаем изделие (напомню, мы заказывали юникс-бинарник для начала):
Ну что ж, работает. Напоследок посмотрим, что там у нас с линковкой на системные библиотеки:
Почти статика. Всё-таки мы заказывали не совсем изолированную вещь, и некоторые сисколлы ей по-видимому нужны.
Есть и пасхалка: при генерации юникс-бинарника фреймворк добавляет процессинг аргументов комстроки (это отдельный рассказ) и некоторые дефолты в этом плане. Например, встроенный минимальный хелп (которым можно управлять, но это тоже отдельный рассказ):
Итак, у нас уже собралось что-то работающее. В следующий раз попробуем пересобрать и запустить поделку в настоящей виртуалке. Наконец-то что-то сделаем НаШкафу(тм).
Stay tuned
Приложение: словарик птичьего языка
- Функтор — в OCaml — модуль высшего порядка, он же «модуль модулей», он же «функция из модуля в модуль». Коротко: можно объявлять выражения, которые принимают в качестве аргументов модули и возвращают тоже модули. Так образуется очень гибкая система управления зависимостями внутри кода и реализуется всякая мета-магия. Например, в нашем случае есть аппаратно-независимый исходник, а на выхлопе компиляции — бинарник, завязанный на конкретные реализации драйверов аппаратуры (таймер ОС).
- Сигнатура — совокупность описания типов данных и значений (в т.ч. функций) модуля. Аналогична «интерфейсу» в яве.
- Lightweight threads — функции, превращённые в промизы, которые собирает и исполняет шедулер мультитредового рантайма.
- Unit — специальный тип, в котором есть только одно значение — «пустышка». В коде пишется как
()
. Используется для передачи функциям «никаких» параметров или возврата «ничего» — в функциональном языке таки надо всегда что-то возвращать из функций. - Хвостатая рекурсия — композиция рекурсивного выражения, в которой рекурсивный вызов является последним во фразе. Программист при этом мыслит рекурсивно, а компилятор генерит машинно-эффективный итеративный код. Вообще в OCaml-е разрешены итерации, но используют их редко. В нашем случае мы вообще живём в монаде, поэтому должны в принципе мыслить вызовами функций, даже если они «цикличны» c точки зрения Визуал Бейсика (тм).
- Монада — TL;DR: специальная сущность, в которой функции вызывают друг друга, передавая данные по цепочке. Можно представить себе как специальный FP-DSL для определения порядка вычислений. Примеры из жизни — пайп в юникс-шеллах типа
cat < 1.txt | bzip2 > 1.txt.bz2
— отправляем файл в «монаду» (из функций cat и bzip2), монада что-то там с ним делает, и на выхлопе получается забзипованный файл. Также монадой является школьная игра в «сифака» — в монаду отправляется классная тряпка, возвращается мятая тряпка, а в качестве сайд-эффекта — взмыленные школьники и возросшая энтропия вселенной.