В этой статье мы расскажем, как запустить программу на 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.
- имитирует плагин SE2, экспортируя метод
-
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();
Соединяем всё вместе
Увидеть, как всё это работает вместе, можно на показанном ниже примере приложения.
- Когда хост Wasm вызывает
_start
в модуле Wasm, он будет внутренне вызывать_initialize
, чтобы- добавить плагин
sdk
в качестве встроенного модуля Python - инициализировать интерпретатор Python
- загрузить модуль
plugin
(который импортирует встроенный модульsdk
)
- добавить плагин
- Когда хост Wasm вызывает
run_e
в модуле Wasm, он- ищет функцию
run_e
из модуляplugin
- транслирует аргументы при помощи
Py_BuildValue
- вызывает функцию на Python с этими аргументами
- ищет функцию
- Когда
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-релиза. Не забудьте упомянутые выше опции компоновщика.
dyadyaSerezha
WebAssembly не язык.
boris-the-blade Автор
Привет! Спасибо, пофиксил.