В этой статье мы расскажем, как запустить программу на Python внутри другого приложения, использующего среду исполнения Wasm (хост), и заставить программу на Python общаться с хостом, и наоборот.

Пару месяцев назад мы добавили Python в Wasm Language Runtimes. Мы опубликовали собранный двоичный файл python.wasm, который можно использовать для выполнения скриптов на Python при помощи WebAssembly, чтобы обеспечить повышенную защиту и портируемость.

После этого релиза мы получили много отзывов о том, как сделать его ещё более полезным для разработчиков. Одной из часто упоминаемых тем стала необходимость двунаправленной связи между хостом на Wasm и кодом на Python, выполняемым в python.wasm.

Мы поработали над этом вместе с командой Suborbital и реализовали приложение, демонстрирующее двунаправленную связь благодаря реализации SE2 Plugin ABI. Эту работу позже внедрили в Suborbital SE2.

Пример приложения можно найти в WLR/python/examples/bindings/se2-bindings. Его легко запустить и оно позволит вам разобраться, как встраивать Python в приложение на Wasm и реализовывать привязки для двунаправленной связи.

Вводная информация


WebAssembly — это отличная технология для расширения возможностей приложений. С одной стороны, она предоставляет среду с песочницей, где расширения могут безопасно выполняться в том же процессе, что и основное приложение. С другой стороны, она позволяет писать расширения на любом языке, которые можно встраивать в модуль Wasm или интерпретировать с его помощью, например python.wasm.

Всё больше веб-приложений и платформ предлагают возможность расширения поверх WebAssembly. Он используется serverless-платформами наподобие Cosmonic wasmCloud, CloudFlare Workers, Fastly Compute, Fermyon Cloud, нашей собственной Wasm Workers Server, а также решениями для расширения возможностей готовых приложений, например, Dylibso Extism, LoopholeLabs Scale и Suborbital Extension Engine.

Расширение возможностей приложений


Расширение возможностей приложений при помощи внешнего кода обеспечивает большую гибкость процессов разработки ПО. В компании может быть сообщество разработчиков, пишущих расширения для одного приложения, или у платформы может быть превосходная базовая функциональность, которую разработчики способны многократно использовать, надстраивая поверх неё расширения.

Традиционно это реализуется при помощи так называемых плагинов, которые загружаются в основное приложение и обеспечивают собственную функциональность. Однако обычно это ограничивает возможности реализаторов плагинов применением конкретного языка программирования (или множества языков), а также привносит угрозу стабильности и защите основного приложения.

При создании веб-приложений разработчики могут воспользоваться WebHooks, которые позволяют множеству веб-приложений работать как одно, и каждое из них можно реализовать на своём языке. Однако такой подход подразумевает снижение скорости коммуникаций между разными веб-приложениями и усложнение структуры развёртывания.

Расширение возможностей при помощи WebAssembly


Чтобы расширить возможности приложения с помощью WebAssembly, нужно встроить в него среду исполнения Wasm, чтобы оно могло исполнять код из модуля Wasm. Мы называем такое приложение хостом Wasm. Коммуникации между хостом Wasm и модулем Wasm чётко заданы экспортируемыми и импортируемыми функциями, объявленными в модуле Wasm. Экспортированные функции реализованы модулем и могут вызываться хостом, а импортированные функции (также называемые функциями хоста) реализованы хостом и могут вызываться модулем.

Однако когда модуль встраивает среду исполнения Python для выполнения скрипта на Python, не существует чётко заданного способа для вызова хостом Wasm функции из скрипта на Python, и наоборот. Чтобы упростить решение этой задачи, нам нужно добавить в модуль Wasm код, который будет раскрывать функции хоста скрипту на Python и функции на Python хосту. Мы называем такой код привязками (binding), поскольку он транслирует API модуля Wasm коду на Python.

При проведении первых экспериментов с созданием таких привязок у нас была дискуссия с Suborbital, которая как раз начинала работу над добавлением поддержки Python в её движок SE2. Мы решили сотрудничать. Поэкспериментировав с libpython и с Python C API, мы пришли к работающему решению, о котором расскажем в этой статье.

Команда Suborbital присоединилась к нашим трудам и довольно быстро выкатила поддержку плагинов на Python в SE2.

Зачем над этим работать


Основная причина упрощения коммуникаций между хостом Wasm и Python — многократное использование кода. В мире есть много разработчиков на Python и кода на нём. Хотя мы можем запускать скрипты на Python в python.wasm, пока всё ещё нет устоявшегося способа коммуникаций с хостом Wasm. При необходимости разработчикам приходится искать свой путь через пробы и ошибки.

Одна из основных задач Wasm Labs — ускорение внедрения WebAssembly, поэтому мы решили, что работа над демонстрационным примером того, как реализовать эту двунаправленную коммуникацию может помочь разработчикам сократить разрыв между Wasm и их готовыми приложениями на Python.

Мы решили объединить усилия с Suborbital, которой эта функциональность нужна была в рамках её платформы.

Предыдущие работы


Всё это основано на работе над python.wasm, проведённой командой CPython. Она уже подготовила целевую сборку wasm32-wasi, которую мы ранее использовали для публикации многократного использования двоичных файлов Python. Нам достаточно было лишь добавить к выпущенным ресурсам libpython (которую эта команда тоже собрала).

У команды Suborbital уже имелся чётко прописанный ABI для коммуникаций между хостом Wasm и написанными на JavaScript плагинами, интерпретируемыми модулем Wasm. Мы можем запросто надстраивать функциональность над уже работающими приложениями и реализовать их для другого языка.

Краткий обзор


Наше приложение состоит из трёх компонентов:

  • se2-mock-runtime: хост WebAssembly.
  • py-plugin: приложение на Python.
  • wasm-wrapper-c: приложение на Wasm, предоставляющее привязки для коммуникаций между двумя другими компонентами.

На диаграмме ниже показан поток исполнения приложения:

  • se2-mock-runtime вызывает run_e, определённый в модуле plugin.py.
  • plugin.py вызывает return_result, определённый в se2-mock-runtime


Запускаем код


Для запуска достаточно Docker. Тогда запуск этого примера сводится к следующему:

export TMP_WORKDIR=$(mktemp -d)
git clone --depth 1 https://github.com/vmware-labs/webassembly-language-runtimes ${TMP_WORKDIR}/wlr
(cd ${TMP_WORKDIR}/wlr/python/examples/bindings/se2-bindings; ./run_me.sh)

После чего вы получите вывод с подробным логированием, который выглядит так:


Рядом с методами, используемыми для явного управления памятью, можно заметить несколько дополнительных логов. Они объяснены в сопровождающем пример файле README.md, и чтобы не усложнять, здесь мы их рассматривать не будем.

Для упрощения чтения логи организованы следующим образом:

  • Логи se2-mock-runtime находятся в начале строки, а имя файла указано тёмно-оранжевым цветом
  • Логи wasm-wrapper-c расположены с отступом в один tab, а имя файла указано зелёным цветом
  • Логи plugin.py расположены с отступом в два tab, а имя файла указано фиолетовым

Погружаемся вглубь


Чтобы лучше разобраться в коде и во всём демонстрационном приложении, взгляните на него в GitHub. Там вы найдёте инструкции по тому, как запускать его, а также более подробное описание компонентов.

Обзор


Давайте подробнее рассмотрим каждый из компонентов.

  • se2-mock-runtime — приложение Node JS, имитирующее среду исполнения Suborbital SE2
    • загружает модуль Wasm (эквивалентный плагину SE2)
    • вызывает его метод run_e для исполнения логики плагина с примером скрипта
    • предоставляет return_result и return_error, которые используются плагином для возврата результата исполнения
  • wasm-wrapper-c — модуль Wasm (написанный на C), который
    • имитирует плагин SE2, экспортируя метод run_e и используя импортированный return_result или return_error
    • использует libpython для встраивания интерпретатор, таким образом передавая реализацию run_e скрипту на Python.
  • py-plugin — приложение на Python
    • исполняется приложением wasm-wrapper-c
    • предоставляет реализацию run_e на чистом Python — «переворот строк, содержащих только символы (без ', . и так далее)»

Использование libpython.a и Python C API


Внедрить интерпретатор Python было довольно просто. Python C API хорошо задокументирован и есть множество примеров опенсорсного ПО.

Сложности работы с WASI возникают вследствие отсутствия поддержки динамических библиотек. Из-за этого нам приходится использовать libpython как статическую библиотеку и компоновать её в наш модуль Wasm. Для удобства мы добавили к нашему Python-релизу libpython tarball. Он содержит все необходимые заголовки, а файл libpython.a — это толстая библиотека, содержащая все остальные зависимости (например, zlib, libuuid, sqlite3 и так далее).

Кроме того, сложным приложениям на Python, скорее всего, понадобится увеличенный размер стека. Чтобы гарантировать достаточный размер и его правильную конфигурацию, мы добавили в файл конфигурации опции компоновщика в представленный выше tarball.

// Linker configuration in lib/wasm32-wasi/pkg-config/libpython3.11.pc.
-Wl,-z,stack-size=524288 -Wl,--stack-first -Wl

Это обеспечивает достаточно большой стек размером в полмегабайта. Кроме того, при этом стек помещается перед глобальными данными, гарантируя таким образом, что любое переполнение стека приведёт к немедленному прерыванию Wasm вместо незаметного повреждения глобальных данных (в некоторых случаях).

Подробнее о компоновке приложения на C с libpython из сред исполнения языка WebAssembly можно прочитать в файлах build-wasm.sh и CMakeLists.tst в WLR/python/examples/bindings/se2-bindings/wasm-wrapper-c/.

Вызов функции на Python из хоста


Допустим, у нас есть функция в plugin.py. Как вызвать её из хоста Wasm?

def run_e(payload, id):
    """Processes UTF-8 encoded `payload`. Execution is identified by an `id`.
    """
    log(f'Received payload "{payload}"', id)

Чтобы получить возможность вызова из хоста Wasm, нужно сначала выполнить экспорт из модуля Wasm, в который встроен интерпретатор Python. Так как лучше использовать простые типы, мы представим строку как указатель и длину.

__attribute__((export_name("run_e"))) void run_e(u8 *ptr, i32 len, i32 id);

Затем можно легко транслировать этот метод в метод Python при помощи Python C API. Если пропустить обработку ошибок и памяти, всё сводится примерно к такому:

void run_e(u8 *ptr, i32 len, i32 id) {
  PyObject *module_name = PyUnicode_DecodeFSDefault("plugin");
  PyObject *plugin_module = PyImport_Import(module_name);
  PyObject *run_e = PyObject_GetAttrString(plugin_module, "run_e");
  PyObject *run_e_args = Py_BuildValue("s#i", ptr, len, id);
  PyObject *result = PyObject_CallObject(run_e, run_e_args);
}

Основной интересующий нас метод здесь — это Py_BuildValue; также нам нужна документация Python о Building values.

Вызов функции хоста из кода на Python


Допустим, у нас есть такая функция хоста:

/** Can be called to return UTF-8 encoded result for an execution `id`
  @param ptr Pointer to the returned result
  @param len Length of the returned result.
  @param id Execution id
*/
void env_return_result(u8 *ptr, i32 len, i32 id)
    __attribute__((__import_module__("env"),
                   __import_name__("return_result")));

Чтобы позволить коду на Python в plugin.py получать доступ к ней, нужно создать модуль Python на C, который будет выполнять трансляцию чего наподобие def return_result(result, id) в показанную выше функцию.

Пример реализации (без обработки ошибок) модуля SDK с такой функцией будет выглядеть следующим образом:

static PyObject *sdk_return_result(PyObject *self, PyObject *args) {
    char *ptr;
    Py_ssize_t len;
    i32 id;
    PyArg_ParseTuple(args, "s#i", &ptr, &len, &id);
    env_return_result((u8 *)result, result_len, ident);
    Py_RETURN_NONE;
}

static PyMethodDef SdkMethods[] = {
    {"return_result", sdk_return_result, METH_VARARGS, "Returns result"},
    {NULL, NULL, 0, NULL}};

static PyModuleDef SdkModule = {
    PyModuleDef_HEAD_INIT, "sdk", NULL, -1, SdkMethods,
    NULL, NULL, NULL, NULL};

static PyObject *PyInit_SdkModule(void) {
    return PyModule_Create(&SdkModule);
}

Здесь ядром всего снова является PyArg_ParseTuple, который хорошо задокументирован в документации Python об аргументах парсинга.

Наконец, прежде чем мы инициализируем интерпретатор Python, нам нужно добавить в список встроенных модулей модуль 'sdk'. Он станет доступен благодаря import sdk в интерпретируемых модулях Python.

PyImport_AppendInittab("sdk", &PyInit_SdkModule);
Py_Initialize();

Соединяем всё вместе


Увидеть, как всё это работает вместе, можно на показанном ниже примере приложения.

  1. Когда хост Wasm вызывает _start в модуле Wasm, он будет внутренне вызывать _initialize, чтобы
    • добавить плагин sdk в качестве встроенного модуля Python
    • инициализировать интерпретатор Python
    • загрузить модуль plugin (который импортирует встроенный модуль sdk)
  2. Когда хост Wasm вызывает run_e в модуле Wasm, он
    • ищет функцию run_e из модуля plugin
    • транслирует аргументы при помощи Py_BuildValue
    • вызывает функцию на Python с этими аргументами

  3. Когда run_e в plugin.py вызывает sdk.return_result, реализация в sdk_module
    • транслирует аргумент при помощи Py_ParseTuple
    • вызывает импортированную функцию env:return_result с этими транслированными аргументами


Работа на будущее


Наш пример приложения содержит много ручной работы. В идеальном сценарии мы должны предоставить файл .wit, объявляющий API, и иметь возможность автоматически генерировать код привязок.

Разработчики из многих компаний уже работают над более обобщённым подходом к использованию Python взаимозаменяемо с Wasm на стороне сервера. Отслеживать прогресс можно в стриме ByteCodeAlliance Python guest runtime and bindings в пространстве Zulip.

Попробуйте самостоятельно [всего 5 минут]


Вы можете сами попробовать демонстрационное приложение здесь.

Если хотите создать что-нибудь с нуля, то можете найти уже собранную libpython, являющуюся частью нашего недавнего Python-релиза. Не забудьте упомянутые выше опции компоновщика.

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


  1. dyadyaSerezha
    18.08.2023 11:30

    Пару месяцев назад мы добавили Python в среды исполнения языка WebAssembly

    WebAssembly не язык.


    1. boris-the-blade Автор
      18.08.2023 11:30
      +1

      Привет! Спасибо, пофиксил.