4 мая 2011 в 13:07

Разработка модулей для Limbo на C (часть 1) tutorial

Модули для Limbo написанные на C так же иногда называют драйверами OS Inferno т.к. они встроены в ядро OS. Необходимость в таких модулях обычно вызвана либо желанием добавить к Limbo отсутствующую в Inferno функциональность (подключить существующие 3rd-party C/C++ библиотеки, дать доступ к специфичным для конкретной host OS syscall-ам) либо желанием выжать максимально возможную производительность (по моим наблюдениям разница в скорости между Limbo с включенным JIT и C примерно 1.3-1.5 раза, но иногда и это может оказаться критичным).

Содержание



Встраиваем модуль в ядро


К сожалению, пока в Inferno нет возможности динамически подгружать модули реализованные на C, поэтому приходится встраивать его прямо в ядро OS. (Это немного осложняет разработку, т.к. после каждого изменения приходится пересобирать Inferno. К счастью, необходимая частичная пересборка занимает примерно секунд 10.)

Для встраивания своего модуля необходимо модифицировать несколько файлов: libinterp/mkfile, module/runt.m, emu/Linux/emu и emu/Linux/emu-g. А поскольку каждый новый модуль пытается встраиваться в эти же файлы в одни и те же места, и таких модулей пользователь может захотеть добавить несколько, причём в неизвестном заранее порядке, то стандартная команда patch не сможет внести необходимые изменения. Один-два модуля она добавит, но со следующими у неё возникнет проблема т.к. редактируемое место в этих файлах начнёт слишком сильно отличаться от того, что она ожидала увидеть.

Для решения этой проблемы я набросал скриптик на Perl — в большинстве случаев достаточно изменить в нём название добавляемого модуля в строчке
my $MODNAME = 'CJSON';

и он внесёт необходимые изменения во все вышеупомянутые файлы встроив ваш модуль в ядро OS Inferno. В более сложных случаях, например когда необходимо подключить к Inferno дополнительные C/C++ библиотеки, этот скрипт придётся модифицировать под ваши нужды (пример такой модификации для подключения C++ библиотеки re2 можно увидеть в модуле Re2). Скрипт можно запускать с параметром -R для отката внесённых изменений.

Итак, скачиваем скрипт, кладём его в $INFERNO_ROOT, переименовываем в patch.example, изменяем в нём имя модуля на «Example», и запускаем. Теперь (несуществующий пока) модуль Example подключён к ядру, осталось его создать и пересобрать Inferno вместе с ним.

Для начала, создадим два файла:
  1. module/example.m
    Example: module
    {
            PATH: con "$Example";
    };
    
  2. libinterp/example.c
    #include <lib9.h>
    #include <isa.h>
    #include <interp.h>
    #include "runt.h"
    #include "examplemod.h"
    
    void
    examplemodinit(void)
    {
            builtinmod("$Example", Examplemodtab, Examplemodlen);
    }
    

И запустим пересборку OS Inferno:
$ (cd libinterp/; mk nuke)
$ rm Linux/386/bin/emu      # work around "text file busy" error
$ mk install

Теперь мы можем написать программу на Limbo, которая успешно подгрузит наш, пока ничего полезного не делающий, модуль:
  • testexample.b
    implement TestExample;
    include "sys.m";
    include "draw.m";
    include "example.m";
    
    TestExample: module
    {
            init: fn(nil: ref Draw->Context, nil: list of string);
    };
    
    init(nil: ref Draw->Context, nil: list of string)
    {
            sys := load Sys Sys->PATH;
            example := load Example Example->PATH;
            if(example == nil)
                    sys->print("fail to load Example: %r\n");
            else
                    sys->print("Example module loaded\n");
    }
    

И запустить:
$ emu
; limbo testexample.b
; testexample
Example module loaded
;

Как это работает

В процессе сборки файл module/example.m анализируется, и генерируются необходимые C-шные структуры описывающие этот модуль — в отдельном файле libinterp/examplemod.h — и весь его публичный интерфейс (константы, adt-шки, функции) — добавляются в файл libinterp/runt.h, содержащий информацию по всем C-модулям. Эти два .h-файла уже подключены к нашему libinterp/example.c.

Далее, в процессе загрузки OS Inferno будет однократно вызвана функция examplemodinit(), которая должна проинициализировать глобальные данные нашего модуля (если таковые есть) и подключить его (вызовом builtinmod(…)) к ядру Inferno. Вызов builtinmod() устанавливает связь между нашим модулем и псевдо-путём к нему $Example указанным в константе PATH, используемой из Limbo при загрузке этого модуля командой load.

Функции: приём параметров и возврат результата


Числа

Начнём с простых типов данных, чтобы не усложнять пример работой со ссылками.
  • module/example.m
    Example: module
    {
            ...
            increment: fn(i: int): int;
    };
    
  • libinterp/example.c
    ...
    void
    Example_increment(void *fp)
    {
            F_Example_increment *f;
            int i;
    
            f = fp;
            i = f->i;
    
            *f->ret = i + 1;
    }
    

Пересобираем Inferno.

  • testexample.b
    ...
    init(nil: ref Draw->Context, nil: list of string)
    {
            ...
            sys->print("increment(5) = %d\n", example->increment(5));
    }
    

Не забываем перезапустить emu перед запуском нашего примера, т.к.
текущий запущенный emu не содержит в себе модифицированный C-модуль.
$ emu
; limbo testexample.b
; testexample
Example module loaded
increment(5) = 6
;

Как это работает

При сборке, для функции increment() найденной в module/example.m, в файл libinterp/runt.h было автоматически добавлено описание этой функции, её параметров и возвращаемых значений:
void Example_increment(void*);
typedef struct F_Example_increment F_Example_increment;
struct F_Example_increment
{
        WORD    regs[NREG-1];
        WORD*   ret;
        uchar   temps[12];
        WORD    i;
};

Я пока не разбирался, что такое regs; temps добавлен явно для выравнивания; ret это указатель на возвращаемое значение; а i это наш параметр.

Строки

  • module/example.m
    Example: module
    {
            ...
            say: fn(s: string);
    };
    
  • libinterp/example.c
    ...
    void
    Example_say(void *fp)
    {
            F_Example_say *f;
            String *s;
            char *str;
    
            f = fp;
            s = f->s;
    
            str = string2c(s);
    
            print("%s\n", str);
    }
    
  • testexample.b
    ...
    init(nil: ref Draw->Context, nil: list of string)
    {
            ...
            example->say("Hello!");
    }
    
Собираем, перезапускаем, проверяем:
$ emu
; limbo testexample.b
; testexample
Example module loaded
increment(5) = 6
Hello!
;

Как это работает

Вот что получилось у нас в libinterp/runt.h:
void Example_say(void*);
typedef struct F_Example_say F_Example_say;
struct F_Example_say
{
        WORD    regs[NREG-1];
        WORD    noret;
        uchar   temps[12];
        String* s;
};

С noret вместо ret всё понятно, функция say() ничего не возвращает. Тип String* это C-реализация Limbo-вский строк. Найти struct String можно в include/interp.h, функции для работы со строками (вроде использованной в нашем примере string2c()) находятся в libinterp/string.c.

Аналогично реализуется работа с другими Limbo-вскими типами данных: через Array*, List*, etc. Не для всех структур есть готовые вспомогательные функции как для работы со строками, но можно найти достаточно примеров в реализации опкодов виртуальной машины libinterp/xec.c (например, как работать со срезами массивов).

Пользовательские adt объявленные в module/example.m преобразуются в обычные C-шные struct (а pick adt в union). Кортежи так же преобразуются в обычные struct.

Скорее всего после изменения module/example.m вам придётся запустить сборку (которая провалится по ошибке) чтобы обновился libinterp/runt.h и вы увидели какие именно структуры были созданы для ваших данных и поняли как реализовывать работу с ними в libinterp/example.c.

Исключения

Для генерации исключения достаточно вызвать функцию error(). Можно подключить raise.h для возврата стандартных ошибок описанных в libinterp/raise.c или объявить аналогичным образом свои собственные в libinterp/example.c.

Разумеется, если вы выделяли самостоятельно память через malloc(), то перед вызовом error() необходимо эту память освободить, иначе будет утечка. Объекты выделяемые стандартным образом через heap (вроде String* и Array*) освобождать не обязательно, их всё-равно чуть позже найдёт и удалит сборщик мусора. (Более детально о работе heap и сборщика мусора в части 2.)

Возвращаем ссылку

Один неявный момент при возвращении результата из функции связан с тем, что *f->ret физически указывает на ячейку памяти, где должен будет находиться результат выполнения функции после её успешного завершения. Из этого вытекают два следствия:
  1. Если вы сначала положите результат в *f->ret, а потом решите что произошла ошибка и сгенерируете исключение, то произойдёт кое-что невозможное с точки зрения Limbo: функция И вернёт значение И вызовет исключение.
  2. Если в переменной, куда возвращается результат вашей функции, уже лежит какое-то значение (которое тоже является ссылкой, разумеется, ведь тип у этой переменной такой же, как у возвращаемого вашей функцией значения), то вы должны его освободить из памяти до того, как перепишете эту ссылку своей.
Для демонстрации первой проблемы давайте модифицируем нашу функцию
increment() вот таким образом:
  • libinterp/example.c
    ...
    void
    Example_increment(void *fp)
    {
            ...
            *f->ret = i + 1;
            error("some error");
    }
    
  • testexample.b
    ...
    init(nil: ref Draw->Context, nil: list of string)
    {
            ...
            i := 0;
            {
                    i = example->increment(5);
            }
            exception e {
                    "*" => sys->print("catched: %s\n", e);
            }
            sys->print("i = %d\n", i);
    }
    

; testexample
...
catched: some error
i = 6
;

Для решения второй проблемы в C-функциях перед сохранением возвращаемого значения в *f->ret необходимо освободить текущее значение. Обычно это делается либо так:
destroy(*f->ret);
*f->ret = new_value;

либо так (H это C-шный аналог Limbo-вского nil):
void *tmp;
...
tmp = *f->ret;
*f->ret = H;
destroy(tmp);
...
*f->ret = new_value;

Насколько я понял, на данный момент разницы между этими вариантами нет, но если Dis будет переписан для работы одновременно на нескольких CPU/Core, то второй вариант будет корректно работать, а первый нет.

Блокирование Dis


В Inferno используется глобальная блокировка Dis (вероятно, аналогичная широко известному GIL в питоне). C-шные функции вызываются с установленной блокировкой, т.к. безопасно работать со структурами данных Dis (т.е. любыми значениями и переменными доступными из Limbo — включая параметры и возвращаемые значения C-шных функций) можно только с установленной блокировкой.

Но если ваша функция должна выполнить некоторую долгую операцию (например чтение/запись или вызов «тяжёлой» функции из внешней библиотеки или выполнить какие-то длительные вычисления), то необходимо перед этой операцией снять release() блокировку, чтобы Dis продолжил выполняться в другой нити параллельно с вашей функцией, а после снова её поставить acquire() (иначе нельзя будет вернуть результат и вернуться в вызвавший эту функцию код на Limbo). Пример можно найти в реализации sys->read() в файле emu/port/inferno.c:
void
Sys_read(void *fp)
{
        ...
        release();
        *f->ret = kread(fdchk(f->fd), f->buf->data, n);
        acquire();
}


Часть 2.
Alex Efros @powerman
карма
300,5
рейтинг 0,4
Systems Architect, Senior Go/Perl Linux Developer
Похожие публикации
Самое читаемое

Комментарии (9)

  • 0
    Я думаю стоит подумать над тем, чтобы таким образом реализовать обертку для динамической загрузки библиотек в никсах.
  • +3
    Чёрт, как долго я вас ждал со статьями про Inferno!
  • 0
    Вопрос к автору. А какого рода задачи вы решаете с помощью Inferno/Limbo.
    Если возможно на примерах… Очень было бы интересно узнать.
    • +2
      В основном анализ/обработка большого потока данных.

      Но мой выбор Inferno/Limbo был обусловлен не столько типом задач, сколько желанием облегчить себе жизнь — Inferno/Limbo позволяет писать намного более простой код, что экономит массу времени и сил как при разработке, так и при поддержке этого кода.

      Хотя у нас в проекте много самых разных задач, большинство из них должно делать одновременно много разных вещей — от обычного fastcgi сервера обслуживающего параллельно нескольких пользователей (работающего на одном ядре) до сетевых сервисов распределённой обработки данных (работающих на кластере серверов). Изначально всё это писалось на Perl, в событийно-ориентированном стиле, на базе epoll и callback-ов. Код в таком стиле по определению получается достаточно сложным, хотя и вполне эффективным. Единственный способ действительно упростить разработку таких приложений — писать многопоточные приложения в стиле CSP. А это означает либо Limbo, либо Go, либо что-нить не менее экзотичное вроде C под Plan9/p9p.

      По сравнению с C Limbo даёт проверку типов, высокоуровневые примитивы, управлению памятью…

      По сравнению с Go, проекты на Limbo несколько сложнее deploy-ить (впрочем, если на свои сервера, то один раз настроить, и дальше никаких проблем), но зато на Limbo значительно проще писать из-за того, что код выполняется в намного более простой среде OS Inferno, а не обычного *NIX — нет неблокирующего I/O, нет сигналов, etc. и в результате в коде приходится учитывать намного меньше странных особых случаев, возникает меньше странных багов, плюс есть дополнительные фишки самой Inferno в виде Styx/9P, возможности удалённо отлаживать работающие или уже упавшие процессы, …

      Вот и получается, что проще всего писать именно на Limbo, из имеющихся на данный момент вариантов.
      • 0
        Большое спасибо за развернутый ответ. Давно пытаюсь подступиться к Plan9/Inferno. Как раз стройностью архитектуры они меня и привлекли.
  • 0
    Спасибо за продолжение инфернального цикла статей, я уж боялся, что Вы забросили эту тему… Вопрос немного в сторону от текущей статьи: не могли бы Вы написать о том, как можно было бы эффективно решить какую-либо общеизвестную задачу в инферно? Ну скажем, написать простенький веб-сервер, или что либо подобное. Без исходных кодов, но с описанием того, как бы это работало архитектурно. Мне кажется, именно такой статьи сейчас не хватает :)
    • +1
      Веб-сервер — это, в основном, TCP-сервис плюс парсинг/генерация HTTP-заголовков. Эта функциональность реализуется везде одинаково. Проксирование запросов к FastCGI/SCGI/etc. серверу по тому же TCP тоже делается как обычно. Максимум, где можно поиграться с Inferno-специфичными вещами — в настройке/контроле самого веб-сервера через виртуальную FS и в подгрузке/выполнении CGIшек как Limbo-модулей (что тоже не так уж уникально, и напоминает mod_perl/mod_php, только реализованный намного проще). Так что это не самый удачный пример. У Mechiel (mjl) помимо прочего софта под Inferno есть и реализация веб-сервера — можете глянуть код.

      Архитектура может быть Inferno-специфичной и любопытной скорее у разных служебных утилит, которые работают исключительно внутри Inferno, а не как внешние TCP-сервера. Например, watchdog, который мониторит работающие процессы (как уже существующие, так и запускаемые им самим) и выполняет заданное действие есть процесс падает; retrymount, который встраивается между стандартной командой mount и 9P-сервером как прокси, чтобы контролировать состояние соединения и перезапускать mount есть соединение разорвалось; etc.

      Есть задачи, которые очень сложно решать в других системах, и которые очень просто решаются в Inferno — например, построение системы распределённых вычислений буквально на коленке, средствами sh-скриптов: Простой пример Grid. Но у меня своё отношение к таким примерам. Да, они реально рабочие, да, они наглядно демонстрируют особенности Inferno, но… я пишу код для production, а это значит обработка ошибок, логи, мониторинг, автоматические переподключения к узлам, повтор провалившихся заданий, и т.д. и т.п. что превращает эти простые наколеночные скрипты в полноценные большие приложения, далеко не столь наглядные, зато надёжно работающие.
      • 0
        По поводу вебсервера — есть еще тема параллельной обработки входящих запросов. В традиционных системах это реализуется либо на форках (апач), либо через стейт-машину (нжинкс). Что с инферно, тупо использовать модель апача на инфернальных нитях, а остальное сделает сама система? Есть ли резон использовать стейт-машину? Или есть свой нативный путь?
        Примеры обязательно посмотрю, спасибо.
        • +1
          Да, конечно, на нитях. Это и есть свой нативный путь. Нитей много, они очень лёгкие, обмен информацией между нитями тривиальный — поэтому при любой возможности упростить логику выкинув её часть в отдельную нить — делают именно это. И никаких больше FSM для I/O. FSM намного сложнее в реализации, и необходимость в ней вызвана исключительно дороговизной процессов и сложностью взаимодействия между нитями в *NIX/Win.

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.