Введение
WebAssembly - молодая, но довольно перспективная технология. WASM позволяет упаковать программу в бинарный формат, который можно запускать на любой системе, где поддерживается WASM рантайми (виртуальная машина, запускающая wasm модули). Многие WASM рантаймы так же подразумевают, что модуль будет изолирован от хостовой системы. Но как же тогда происходит взаимодействие между модулем и хостом?
В этой статье посмотрим как WASM-модули взаимодействуют с Python хостом. (Спойлер - не все так просто как хотелось, поддержка WASM в Python экосистеме пока слабая)
Итак, в мире WASM существует две части:
Host - окружение, которое запускает wasm и управляет его выполнением (например, браузер или рантайм вроде wasmtime).
-
Guest - сам wasm-модуль, который может быть собран из кода на rust / C / C++, и вроде бы есть даже эксперименты с java / python.
(Фактически для разработки wasm-модуля лучше всего подходит rust)
Guest может экспортировать / импортировать функции. Экспортируемая функция - предоставляется модулем, и может быть вызвана на хосте. Импортируемая функция наоборот - предоставляется хостом для модуля (например вывод в stdout). Никаких других способов доступа к хостовой системе у модуля нет.
Для вызова функций доступны только простые типы, вроде bool
и i32
(на самом деле другие примитивные типы тоже конвертируются в i32
).
Если нужно передавать сложные данные (строки, структуры, векторы), они передаются через линейную память.
Память в wasm
Host ↔ Guest взаимодействие работает через линейную память. Оба - и host, и guest - имеют полный доступ к памяти инстанса (виртуальной машины wasm). Например:
Host хочет передать данные в инстанс, для этого записывает их в память инстанса по определенному адресу.
Затем host вызывает wasm-функцию, передавая ей
i32
-указатель на эти данные.Код wasm читает данные по этому адресу.
Component Model
В январе 2024 Bytecode Alliance представила Component Model - более структурированный и стандартизированный способ взаимодействия модулей друг с другом и с хостом. Для описания интерфейсов взаимодействия используется язык WIT.
WIT
Язык wasm interface type (wit) нужен для описания интерфейсов взаимодействия между host и guest.
Пример:
package xyz:machine;
interface machine-interface {
type machine-id = u64;
record point {
x: u32,
y: u32,
}
record run-result {
machine-id: machine-id,
message: string,
}
get-u32: func() -> u32;
get-str: func() -> string;
get-vec: func() -> list<u8>;
run: func(machine: machine-id, start: point, destination: point) -> run-result;
count-symbols: func(s: string) -> u32;
}
world machine {
export machine-interface;
}
Благодаря этому, разработчики могут не имлементировать интерфейсы вручную, а генерировать код для host/guest с помощью инструментов вроде wit-bindgen
, wasmtime-bindgen
и др.
Проблема в Python
Но... Python пока не поддерживает все эти удобные инструменты. Что же делать если хочется запускать wasm модули из python кода?
Придется реализовывать все взаимодействие со стороны python вручную. Но если модуль будет поддерживать wit, то это все равно упрощает дело, т.к можно например посмотреть на модуль и понять какие у него интерфейсы.
Допустим, wasm-модуль написан на Rust с использованием wit-bindgen
.
Rust (guest)
Возьмем WIT из примера выше и напишем код модуля, который будет выпонять простые функции.
wit_bindgen::generate!({
path: "wit",
world: "machine",
});
use crate::exports::xyz::machine::machine_interface::{Guest, MachineId, Point, RunResult};
struct Machine;
impl Guest for Machine {
fn get_u32() -> u32 {
123
}
fn get_str() -> String {
String::from("simple string to return")
}
fn get_vec() -> Vec<u8> {
return vec![5, 4, 3, 2, 1, 10];
}
fn run(machine: MachineId, start: Point, destination: Point) -> RunResult {
RunResult {
machine_id: 1,
message: format!("machine {machine} run from {start:?} to {destination:?}"),
}
}
fn count_symbols(s: String) -> u32 {
s.chars().count() as u32
}
}
export!(Machine);
wit_bindgen
читает wit файл и генерирует трейт Guest
, который нужно имплементировать.
Python (host)
Тут будет несколько блоков. Функции с простыми аргументами (bool, числа) не требуют какого-то дополнительного кода. Но т.к инструменты кодогенерации из WIT для python не работают, то для передачи строк, массивов и других сложных структур понадобится работать с линейной памятью.
Сначала простое.
Простые аргументы и возвращаемые значения
import struct
from wasmtime import loader, Config, Engine, Store, Module, Linker
engine = Engine()
store = Store(engine)
linker = Linker(engine)
wasm_intro = Module.from_file(engine, "wasm_intro.wasm")
instance = linker.instantiate(store, wasm_intro)
exports = instance.exports(store)
memory = exports['memory']
print([e for e in exports])
# ['memory', 'xyz:machine/machine-interface#get-u32', 'xyz:machine/machine-interface#get-str', 'cabi_post_xyz:machine/machine-interface#get-str', 'xyz:machine/machine-interface#get-vec', 'cabi_post_xyz:machine/machine-interface#get-vec', 'xyz:machine/machine-interface#run', 'cabi_post_xyz:machine/machine-interface#run', 'xyz:machine/machine-interface#count-symbols', 'cabi_realloc']
# call function
get_u32 = exports['xyz:machine/machine-interface#get-u32']
print(get_u32(store)) # 123
Тут можно заметить, что каждая функция xyz:machine/*
имеет пару cabi_post_xyz:machine/*
. Эта вторая функция нужна для очистки памяти, после того как вернувшийся результат был обработан - иначе данные останутся лежать в памяти инстанса.
Для примитивных типов это не нужно, но если для того чтоб вернуть ответ модуль выделил память - ее нужно почистить:
Сложные возвращаемые значения (строки / массивы)
# string
get_str = exports['xyz:machine/machine-interface#get-str']
cabi_post_get_str = exports['cabi_post_xyz:machine/machine-interface#get-str']
def parse_string(memory, store, ptr) -> str:
STR_SIZE = 8
string_struct = memory.read(store, ptr, ptr + STR_SIZE)
str_ptr, str_len = struct.unpack('<II', string_struct)
str_bytes = memory.read(store, str_ptr, str_ptr + str_len)
return str_bytes.decode()
str_ptr = get_str(store)
print(parse_string(memory, store, str_ptr)) # "simple string to return"
# vector of u8
get_vec = exports['xyz:machine/machine-interface#get-vec']
cabi_post_get_vec = exports['cabi_post_xyz:machine/machine-interface#get-vec']
def parse_u8_vec(memory, store, ptr) -> list[int]:
U8_SIZE = 1
bytes = memory.read(store, ptr, ptr + 8)
data_ptr, length = struct.unpack('<II', bytes)
print(data_ptr, length)
result = []
next_elem_ptr = data_ptr
for _ in range(length):
u8_bytes = memory.read(store, next_elem_ptr, next_elem_ptr + U8_SIZE)
number = struct.unpack('<B', u8_bytes)[0]
result.append(number)
next_elem_ptr += U8_SIZE
return result
vec_ptr = get_vec(store)
print(parse_u8_vec(memory, store, vec_ptr)) # [5, 4, 3, 2, 1, 10]
Тут как раз используется cabi_post*
для освобождения строки / массива.
Сложные аргументы
Чтобы передать строку в wasm-модуль, нужно сначала записать её в память. Но куда?
wit_bindgen
(который использовался со стороны wasm-модуля) генерирует функцию cabi_realloc
- с помощью нее можно выделить память. Функция возвращает адрес в линейной памяти.
count_symbols = exports['xyz:machine/machine-interface#count-symbols']
cabi_realloc = exports['cabi_realloc']
encoded_str = "string with symbols (~25)".encode()
ptr = cabi_realloc(store, 0, 0, 4, len(encoded_str))
memory.write(store, encoded_str, ptr) # write to instance memory
count = count_symbols(store, ptr, len(encoded_str)) # note ptr is offset of the string in instance memory
print(count) # 25
Стоит обратить внимание, что хотя тут и происходит аллокация, очищать память инстанса от записанной строки не нужно со стороны хоста. Предполагается, что после вызова wasm-функции count_symbols
инстанс забирает ответсвенность за эту память на себя, и должен будет самостоятельно о ней позаботиться.
Если же попытаться сделать деаллокацию с хоста, то будет ошибка (из-за двойного освобождения).
Заключение
Несмотря на то, что WebAssembly выглядит очень перспективным для взаимодействия между разными компонентами на разных языках, поддержка инструментов для языков, кроме Rust, пока ограничена. Хотя, как мы видели, многое можно делать и "вручную".
WASM выглядит многообещающим и как более легковесная альтернатива контейнерам.
Дополнительно
Есть несколько полезных CLI-инструментов, которые помогут в работе с wasm:
wasm-objdump
- инспекция wasm-бинарей, полезно для отладки / понимания структуры.wasm2wat
- конвертация.wasm
→ WAT (человекочитаемый текстовый формат).wit-bindgen
- генерация биндингов host/guest из.wit
-описаний.
Pavel_Agafonov
Те же Bytecode Alliance сделали тулзу, с помощью которой можно генерить биндинги и собирать Python-код в wasm-компонент. Не знаю, на какой стадии развития это находится, но полгода назад удавалось собрать HTTP-хендлер