Часть 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), монада что-то там с ним делает, и на выхлопе получается забзипованный файл. Также монадой является школьная игра в «сифака» — в монаду отправляется классная тряпка, возвращается мятая тряпка, а в качестве сайд-эффекта — взмыленные школьники и возросшая энтропия вселенной.

--

--