WebAssembly (WASM) — это открытый стандарт и компактный бинарный формат байт-кода, созданный для выполнения кода в браузере с околонативной скоростью. Он работает внутри безопасной виртуальной машины и поддерживается всеми популярными браузерами (Chrome, Firefox, Safari, Edge). WASM не является самостоятельным языком программирования. Это target для C, C++, Rust, Go и других языков.
Хоть технология и создавалась для решения проблем веба и браузеров, с выходом WASI WASM шагнул в сторону серверной и IoT-разработки.
WASI (WebAssembly System Interface)
WASI (WebAssembly System Interface) — это стандартизированный системный интерфейс (API), который позволяет запускать модули WebAssembly вне браузера
Кроссплатформенность, околонативная скорость, безопасность и изоляция сильнее, чем у Docker-контейнеров делает эту технологи крайне интересной в серверной разработке (как минимум для экспериментов).
Типичный сервер с бизнес-логикой не обходится исключительно CPU-bound операциями, ему почти всегда нужны данные, за данными необходимо ходить по сети. При выборе WASM из за его скорости было бы странно обойти стороной оптимизацию compute нашего сервера: асинхронный неблокирующий I/O.
Давайте попробуем разобраться, какие возможности нам сегодня предоставляет экосистема WASM. Для начала явно обозначим задачу
Важное замечание
Подразумевается, что у читателя есть некоторые представления об экосистеме WASM — WASI, WIT, Component Model, guest и host, линейная память, proposals и т.д. — многие абстракции в статье не будут описаны подробны в угоду простоты и читабельности :). Пишите в комментариях, что необходимо раскрыть более подробно.
Зачем нам нужна асинхронность
Представим сервер с бизнес-логикой, обслуживающий и порождающий HTTP-трафик. При обслуживании внешнего запроса сервер вызывает мастер системы, чтобы обогатиться данными. Данные зачастую независимы, или наоборот — имеют сложные связи, но в любом случае походов во внешние системы много, блокировки и простой воркеров будут приводить к деградации системы.
WASM-компонент изолирован и сам не делает сетевых вызовов. HTTP, диск, таймеры — все это выполняет хост (Wasmtime). Но именно отсюда и вырастает потребность в асинхронности.
Почему Wasmtime
WASM — всего лишь инструкции, которые кто-то должен исполнять, поэтому при выборе WASM вам нужно будет еще и выбирать рантайм для него. Я остановился на Wasmtime по причинам:
-
Единственный зрелый рантайм с полной поддержкой WASI
-
Есть биндинги есть для многих языков (
wasmtime-go,wasmtime-py)
Однако, этот вопрос можно обсуждать более подробно, но это не касается темы данной статьи.
Когда компонент вызывает импорт wasi:http/outgoing-handler, под капотом компонент передает управление хосту, который инициирует реальный HTTP-запрос, а ответ придет через некоторое время. Всё это время компонент может выполнять полезную работу (например, второй HTTP-вызов, независимый от первого).
В синхронном режиме компонент застывает на импорте, пока хост не вернет результат (слово «блокируется» не употребляю сознательно, т.к. это значит именно застывание на границе ABI, а не блок потока). Один компонент = одна задача в один момент (инвариант 2). Чтобы обработать 10 000 одновременных HTTP-соединений на сервере, хосту придётся запустить 10 000 инстансов компонента. Это убивает серверный сценарий.
Асинхронность нам нужна потому, что компонент ждёт результата I/O от хоста, и это ожидание должно быть кооперативным, а не блокирующим.
WASI 0.2: имитация async
Для реализации асинхронности в WASI 0.2 используются абстракции: pollable, input-stream, output-stream. Типичные паттерны реализации:
-
resource pollable -
resource input-stream -
resource output-stream -
poll(list<pollable>) -
subscribe()наresource -
и т.д.
Как видно из названий — все строится вокруг pollable-объектов и ручного ожидания.
В 0.2 экспорт компонента всегда синхронный по природе: вызвав handle(request_1), хост не может войти в этот же инстанс с handle(request_2), пока первый вызов не вернется. Конкурентность внутри одного инстанса достигается только через poll() внутри гостевого кода + ручной event loop в госте.
Проблема в том, что на стороне исходных языков (backend, из которого компилируется WASM-компонент) существуют разные моделями concurrency (OS Threads, stackful coroutines, stackless coroutines), соответственно конкретные реализации отличаются и идеологически и в плане производительности. Из этого следует, что host-сторона должна знать и учитывать concurrency-модель исходного языка WASM-компонента (требуются runtime-specific адаптеры). Иными словами, невозможно корректно выразить в ABI: async fn foo() -> T между Rust-компонентом, Go-компонентом, Java-компонентом и т.д. — каждый рантайм изобретает собственный способ ожидания.
Коротко о ключевых отличиях
Существует две парадигмы реализации корутин — stackful coroutines (fiber, green thread, virtual thread, user-mode thread, goroutine) и stackless coroutines (state machine, coroutine).
В случае со stackful coroutine сущности исполнения принадлежит собственный полноценный стек вызовов и собственный набор регистров процессора, сохраняемые при приостановке. Переключение происходит в user space, без участия ядра ОС, кооперативно, т.е. приостановка инициируется самим кодом, а не таймером ядра.
В случае со stackless coroutines у сущности исполнения нет отдельного стека. Компилятор статически анализирует тело async-функции, выделяет все live-переменные через каждую suspend точку и генерирует конечный автомат (state machine). Переключение происходит через коллбэки.
Замечательные статьи на эту тему:
Если же компонент собран из нескольких дочерних, каждый компонент должен иметь свой собственный event loop, это значит, что координация между компонентами будет головной болью.
Еще одной загвоздкой WASI 0.2 является то, что терминальные ошибки всплывают только на вызове read у stream. Это означает, что вызывающая сторона узнает об исходе, только если продолжит читать. Если читатель останавливается раньше, он не может отличить закрытие stream от ошибки. Это readiness-модель, которая описывает готовность, а не завершение. pollable сигнализирует «можно читать», а не «вот тебе данные».
WASI 0.3
Основная идея Async Component Model: до появления native async в Component Model асинхронность была проблемой конкретного языка или конкретного runtime. Каждая экосистема решала задачу по-своему. WASI 0.3 поднимает async на уровень Component Model. Теперь WIT может описывать асинхронный контракт напрямую: foo: async func(s: string) -> string После этого любой runtime понимает, что вызов foo() может завершиться позже и требует асинхронной обработки результата. Это свойство становится частью ABI, а не частью языка.
Работа, которую в WASI 0.2 выполняли pollables, input-streams и output-streams, теперь входит в canonical ABI, где Component Model предоставляет эти примитивы нативно. Новые async-примитивы являются частью canonical ABI Component Model, что позволяет генераторам bindings выдавать идиоматичные async-bindings для соответствующего языка.
В WASI 0.2 каждому компоненту требовался собственный event loop. Это означало, что отдельные компоненты можно было запускать на host, но эти event loops не имели способа координироваться между собой. Если компонент использовал streaming или async API, его нельзя было скомпоновать с другими компонентами.
WASI 0.3 устроен так, что теперь host управляет единственным event loop, общим для всех компонентов. Это стало возможным благодаря добавлению stream<T>, future<T> и async как first-class конструкций canonical ABI.
В WASI 0.2 приходилось делать приседания, чтобы заставить async работать, но теперь async нативен для component model, а значит можно выражать то же самое гораздо эргономичнее. Обзор паттернов в WASI 0.2 и того, как эти же паттерны выглядят в 0.3 с Component Model async:
|
WASI 0.2 |
WASI 0.3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
возврат |
|
|
|
Проблемой WASI 0.2 было то, что терминальные ошибки всплывали на каждом вызове read у stream. Это означало, что вызывающая сторона узнавала об исходе, только если продолжала читать. Если читатель останавливался раньше, он не мог отличить закрытие stream от ошибки. В WASI 0.3 streams теперь возвращают дополнительный future, который резолвится независимо от того, сколько было потреблено из stream, решая проблему статуса stream из WASI 0.2:
// WASI 0.2read-via-stream: func() -> result<input-stream, error-code>;// WASI 0.3read-via-stream: func() -> tuple<stream<u8>, future<result<_, error-code>>>;
Киллер-фича component model в том, что он делает тривиальным создание bindings к другим языкам и из них. С добавлением first-class async это означает, что генераторы guest-bindings могут использовать его для создания async-bindings, используемых нативно в соответствующем языке.
Разные языки опираются на stackless или stackful coroutines. Async ABI Component Model изначально проектировался так, чтобы совмещать одновременно оба подхода.
Манифесты при разработке WASI 0.3
-
Fiber-like stack switching без зависимости от core wasm proposal: Async ABI должен предоставлять поведение fiber (stackful coroutine). При этом он должен быть специфицирован в терминах core wasm stack-switching proposal, но не должен реально требовать его реализации в рантайме. То есть Wasmtime не нужен core wasm typed continuations, чтобы поддержать async, он может реализовать его через собственные нативные стеки (Wasmtime так и делает, чуть ниже об этом подробнее).
-
Никакой раскраски функций:
asyncв JavaScript/Python/Kotlin заражает всю цепочку вызывающих функций. Component Model отказывается от этой проблемы: компонент не имеет цвета, sync- и async-функции компонуются в любом направлении. -
Встроенные backpressure и cancellation: backpressure нужна, чтобы caller не завалил систему, cancellation — чтобы можно было корректно бросить ненужную работу.
Реализация
async
async effect type — это атрибут на уровне WIT, помечающий «эта функция может приостановиться перед возвратом значения».
interface processor { process: async func(in: inputs) -> outputs; /* может приостановиться */ ready: func() -> bool; /* не может приостановиться */}
Effect type
Это понятие из систем типов, в которых сигнатура функции описывает не только, что функция возвращает, но и какие наблюдаемые поведения она может проявлять в процессе выполнения: бросить исключение, заблокироваться, выполнить I/O, приостановиться, выделить ресурс и т.п.
Семантика async в WIT — это не «реализована асинхронно», а «может приостановиться». Это разрешение, а не обязанность. Если у функции нет async, и она при выполнении попыталась приостановиться, будет trap.
На уровне ABI в Component Model определяет для каждой функции с типом async неблокирующую core-сигнатуру, которая может использоваться вместо или наряду с уже существующей синхронной core-сигнатурой (из WASI 0.2). Идея в том, что эти сигнатуры предназначены не для ручного написания: их вызывают или реализуют сгенерированные bindings, а те уже мапят низкоуровневый core async-протокол на высокоуровневые конкурентные конструкции конкретного языка.
Посмотрим, как это реализовано.
Async Import ABI
async import означает, что компонент объявляет, что хочет получать от хоста (или другого компонента) функцию, которая может не вернуть результат сразу, и пока она «думает», компонент готов отдать управление и заняться другими задачами. Это точка, где компонент ждет I/O от хоста, не блокируя ни поток хоста, ни сам инстанс.
import bar: async func(s: string) -> string;
Синхронная сигнатура:
(func $bar (param $s-ptr i32) (param $s-len i32) (param $out-ptr i32))
Асинхронная сигнатура:
(func $bar (param $s-ptr i32) (param $s-len i32) (param $out-ptr i32) (result i32))
Как видно, добавился i32 статус-код, описывающий состояние асинхронной операции. Возвращаемый i32 несет две вещи одновременно, упакованные битами:
-
Низкие 4 бита — состояние вызова:
-
0— вызов не начался,$out-ptrне записан -
1— вызов начался, аргументы приняты, но$out-ptrеще не записан -
2— вызов завершился,$out-ptrзаписан
-
-
Высокие 28 бит: индекс новой async-подзадачи в waitable-таблице (которую нужно ожидать) или
0.
Waitable table
Отдельное от resources (но похожее по сути) индексное пространство инстанса, в которое Canonical ABI помещает все объекты, на готовность которых гостевой код может ожидать. К таким объектам относятся: подзадачи (subtasks), порождённые async-вызовами импортов, концы стримов (stream.readable/stream.writable), концы future (future.readable/future.writable). Каждому такому объекту при создании выдается i32-индекс в этой таблице.
Ресурсы
Механизм для инкапсуляции состояния и управления им. Это состояние управляется с одной стороны границы вызова (например, в Rust-коде) и доступно для чтения и изменения с другой стороны (например, в WebAssembly-коде). Ресурс — это дескриптор некоторой сущности, существующей вне компонента WASM.
Критическое следствие для caller: пока соответствующий указатель не помечен как прочитанный/записанный, caller обязан удерживать соответствующую область линейной памяти живой. Нельзя её освобождать, переиспользовать или перемещать. Аргументы остаются одолженными callee, а буфер результата зарезервированным под будущую запись.
Async Export ABI
export async существует для того, чтобы компонент-callee мог сообщить хосту (или другому компоненту-caller): «мой ответ еще не готов, можешь заниматься другими делами». Иначе говоря, async на экспорте — это возврат управления вызывающей стороне до завершения работы.
export foo: async func(s: string) -> string;
Синхронная сигнатура:
(func (param $s-ptr i32) (param $s-len i32) (result $retp i32))
$retp это выровненный указатель в линейную память, по которому caller загрузит пару (pointer, length) строки-результата.
Для асинхронных экспортов предусмотрены два варианта сигнатур: stackful и stackless. Это сознательное проектное решение Component Model: язык со stackful-корутинами (Go или Java Loom) и язык со stackless-корутинами (Rust или Kotlin) получают каждый свою ABI-сигнатуру.
Сигнатура stackful async-экспорта:
;; async, no callback(func (param $s-ptr i32) (param $s-len i32))
У core-функции нет результата. Значение возвращается не через return, а вызовом импортированной функции task.return:
(func (param $ret-ptr i32) (param $ret-len i32))
Параметры task.return работают так, что WIT-тип возврата является WIT-типом параметра обычной синхронной функции. То есть для строкового результата — это пара (ret-ptr, ret-len).
В Stackful-варианте (Go или Java Loom) callee владеет полноценным wasm-стеком, который рантайм сохраняет и восстанавливает при блокировке/возобновлении задачи.
Сигнатура stackless async-экспорта:
;; async, callback(func (param $s-ptr i32) (param $s-len i32) (result i32))
Параметры снова как в синхронном случае. Возврат значения через task.return (точно так же, как в stackful). А вот (result i32) несет совсем не значение функции. Это инструкция рантайму, что делать дальше. Это и есть суть stackless-режима: вместо того чтобы блокироваться внутри функции (сохраняя стек), функция явно возвращается в event loop с пометкой о своем желании приостановиться, освобождая native-стек хост-рантайма. Это идиоматический паттерн корутина как state machine, характерный для языков Rust и Kotlin.
Семантика возвращаемого i32 (снова: низкие 4 бита + высокие 28 бит):
-
Низкие 4 бита — что хочет callee:
-
0— завершился (уже вызвалtask.return), блокирования не было -
1— хочетyield— отдать управление другому коду -
2— хочет ждать события в waitable-set
-
-
Высокие 28 бит: индекс waitable-set или не используется.
Дополнительное обязательство stackless-экспорта: вместе с самой функцией должна быть экспортирована функция-callback с сигнатурой:
(func (param i32 i32 i32) (result i32))
Здесь:
-
(result i32)имеет ту же семантику, что и у самого экспорта. Это позволяет коллбэку говорить рантайму «я снова хочуyield"или «жду на этом waitable-set». -
Три
i32-параметра кодируют событие, из-за которого callback был вызван -
Рантайм будет повторно дёргать callback до тех пор, пока тот не вернет
0.
Таким образом stackless-экспорт работает в режиме: функция -> возврат в event loop -> callback -> возврат в event loop -> callback -> ... -> callback вернул 0 -> задача завершена
Между этими шагами хост может вызывать другие задачи или другие компоненты, native-стек всё это время свободен.
future и stream
Два type в WIT, представляющих concurrency в обоих ABI (и sync, и async). Это позволяет биндить их к идиоматическим конструкциям всех целевых языков: Promises (JS), futures (Rust), streams/channels (Go), и т.д.
Семантически:
-
future<T>— однонаправленный канал на ровно 0 или 1 значение. -
stream<T>— однонаправленный канал на 0..N значений.
У каждого такого объекта есть два конца:
-
readable end (читающий) — кто-то будет вытаскивать из него значения,
-
writable end (пишущий) — кто-то будет туда складывать значения.
Component Model жёстко регулирует владение концами канала. Есть два сценария: компонент получает stream/future (как параметр экспортируемой функции либо как результат импортируемой) или компонент передает stream/future наружу (как параметр импортируемой функции либо как результат экспортируемой). Через границу компонента (в WIT-сигнатурах) всегда проходит только readable end — противоположная сторона границы вызова видит исключительно его. Writable end — это приватная ручка того, кто создал пару вызовом {stream,future}.new, и он остаётся в waitable-table его инстанса.
В сценарии host (Wasmtime) -> WASM это означает: если host вызывает экспорт компонента и передаёт stream<u8>, host сам предварительно создал пару у себя, оставил writable у себя и отдал readable внутрь компонента. Если компонент возвращает host future<T> как результат импорта, writable остаётся у host, а компонент получает readable. Симметрично работает и обратное направление: writable всегда у того, кто будет писать, readable — у того, кто будет читать, и через ABI пересекает границу только readable.
Планированием управляет runtime, а не каждый компонент по отдельности. Когда значение доставлено в future или очередной элемент записан в stream, runtime сам планирует задачу, ожидающую этого события, и делает это даже если readable end успел пройти через границы нескольких компонентов. В чистом сценарии host -> WASM это сводится к простому случаю: writer — это host (Wasmtime), он же владелец writable end и он же event loop.
Writer в общем случае может быть host’ом, другим компонентом или даже тем же компонентом, который держит readable end, но координация остается обязанностью единственного runtime-level event loop.
future<T> и stream<T> могут появляться в любом месте параметров или результатов на любой глубине вложенности. Например, абсолютно валидный тип:
async func(s1: stream<future<string>>, s2: list<stream<string>>) -> result<stream<string>, stream<error>>
Ключевая особенность представления: и в синхронном, и в асинхронном ABI каждый future или stream в WIT-типе превращается в ровно один i32. Этот i32 — это индекс в handle-таблице текущего инстанса компонента.
Например, для:
async func(f: future<string>) -> future<u32>
синхронная сигнатура:
(func (param $f i32) (result i32))
асинхронная:
(func (param $f i32) (param $out-ptr i32) (result i32))
Разберем на примере async func(f: future<string>) -> future<u32>.
Пусть гость хочет прочитать значение из f: future<string>. У него на руках handle $f (i32-индекс в handle-table). Он выделяет в линейной памяти место под результат и вызывает future.read($f, $buf-ptr).
Возвращается i32-статус. Это упакованное число: низкие биты — код исхода, высокие — количество доставленных элементов (для future — 0 или 1, для stream — до len).
Если Writer уже успел положить значение в future до того, как гость пришел читать (немедленный успех (COMPLETED)), рантайм синхронно, прямо в теле future.read сериализует строку в линейную память гостя и возвращает статус с кодом COMPLETED и счетчиком 1. Сразу после возврата гость может читать (ptr, len) по $buf-ptr — данные уже там.
Если Writer еще не положил значение (приостановлено (BLOCKED)). Рантайм запоминает $buf-ptr как место будущей записи и помечает future как имеющий ожидающего читателя и возвращает статус BLOCKED со счётчиком 0. $buf-ptr теперь одолжен рантайму. Гость обязан:
-
не освобождать память по
$buf-ptr; -
не писать в нее;
-
не перемещать;
-
не вызывать повторно
future.readна том же handle.
Дальше гость переключается на другую работу: запускает второй future.read для другого handle, делает task.yield, или (в stackless-режиме) возвращается в event loop с кодом «жду на waitable-set, содержащем $f".
Когда host наконец доставляет значение, рантайм сам копирует строку по ранее запомненному $buf-ptr и помещает в waitable-set гостя событие completion для $f со счетчиком 1. Гость пробуждается (stackful — продолжает с точки future.read , stackless — получает вызов callback с этим событием), читает (ptr, len) по $buf-ptr, handle закрывается.
Для stream<T> тот же протокол, но stream.read(handle, ptr, len) оперирует буфером на len элементов.
Если инстанс держит читающий или пишущий конец потока (stream) или future, противоположный конец которого держит хост, то ожидается, что хост будет поддерживать инстанс живым до тех пор, пока все futures и streams не перейдут в закрытое состояние.
future<T> и stream<T> являются ABI-конструкциями Component Model, а не объектами конкретного языка. Runtime отвечает за доставку событий и прогресс операций даже если readable end проходит через несколько компонентных границ. Именно это делает возможной композицию async-компонентов без передачи pollable-объектов между инстансами.
Модель исполнения и связь со Stack Switching Proposal
Component Model умеет работать с несколькими параллельно идущими вызовами. в Canonical ABI это все формализуется через три слоя, каждый последующий строится поверх предыдущего:
-
Stack switching: примитивы
cont.new,suspend,resumeиз proposal stack-switching ядра WebAssembly. -
Thread: объект, у которого внутри хранитсяcontinuation(снимок остановленного исполнения). Когда поток приостановлен, в нем лежитcontinuation, когда исполняется —continuationпустой. -
Task: соответствует одному вызову компонентного экспорта через границу компонента. Содержит от 1 до NThread.
Stack Switching
Существует языковая фича самого WebAssembly — proposal stack-switching. Он добавляет в wasm новые инструкции (cont.new, resume, suspend, switch) и тип continuation, чтобы wasm-гость сам мог создавать дополнительные стеки и переключаться между ними без участия хоста. Это нужно для эффективной реализации корутин, генераторов, горутин Go и т.п. внутри одного wasm-инстанса.
Component Model спроектирован так, чтобы в будущем красиво сочетаться с настоящим stack-switching из ядра WebAssembly (когда тот будет стандартизован). Поэтому все формализовано через инструкции cont.new / suspend / resume.
Хоть Async ABI Component Model концептуально и формулируется через continuation и stack-switching модель, реализация async ABI не требует поддержки stack-switching proposal в ядре WebAssembly. Runtime вправе реализовать сохранение и восстановление исполнения любым способом. Например, Wasmtime реализует async Component Model без зависимости от стандартизованного stack-switching proposal (об этом ниже в пункте про асинхронность в Wasmtime).
Threads
Что Thread добавляет поверх stack-switching:
-
Ожидание внешнего I/O.
-
Async call stack:
Threadзнает, к какомуTaskон принадлежит. -
Cancellation: флаг
cancellable -
Thread index: индекс в таблице
ComponentInstance.threads. -
Thread-local storage: пара
(int, int), доступная коду компонента.
Tasks
Task это обертка вокруг одного вызова экспорта компонента:
-
знает, кто caller и кто callee
-
хранит коллбэки
-
отслеживает все
Thread, которые работают на него -
хранит состояние отмены
-
управляет backpressure
Все межкомпонентные вызовы (хост-компонент, компонент-компонент и компонент-хост) проходят через одну и ту же спек-функцию FuncInst. Если callee приостанавливается на уровне wasm, FuncInst возвращается caller’у немедленно, а callee продолжает работать в отдельном Thread. Это и есть фундамент async между компонентами.
Task — это конкретный вызов экспортируемой функции компонента. В WASI 0.3 Canonical ABI хост может создать N Task внутри одного инстанса одновременно.
Subtask — это вторая сторона того же самого вызова, что и Task. Когда component A вызывает импорт, реализованный компонентом B, Canonical ABI создает пару объектов: Task на стороне callee (B) и Subtask на стороне caller (A). Они описывают один и тот же вызов с разных концов границы: Task следит за исполнением callee, Subtask — за тем, как caller ждёт результата и удерживает одолженные ресурсы.
Кто чем владеет:
-
Subtaskживет в инстансе caller’а, индексируется в его waitable-таблице, как иfuture/stream. -
Taskживет в инстансе callee.
Граничные случаи:
-
Host вызывает экспорт компонента: создаётся только
Task(у callee).Subtaskнет, потому что у host нет Canonical-ABI-объекта. -
Компонент вызывает импорт, реализованный host: создается только
Subtask(у компонента).Taskнет, потому что callee — это host. -
Компонент вызывает импорт, реализованный другим компонентом: создаётся пара
Subtask+Task.
Каждый async-импорт — это отдельный subtask относительно Task текущего экспорта. Спецификация описывает это так: subtask и readable/writable концы streams/futures собирательно называются waitables и могут быть помещены в waitable sets, на которые тред ожидает прогресса. Но программист не работает с waitable set вручную генераторы биндингов прячут это за обычным Future и его аналогами.
Backpressure
Backpressure становится фундаментом для inter-ABI взаимодействия:
-
Async caller -> sync callee: callee первым делом захватывает
exclusive lockна свой component instance. Если потом callee приостанавливается на suspend point, управление сразу возвращается caller.Lockостаётся захваченным. -
Второй async вызов в тот же instance: при попытке войти в синхронный экспорт он блокируется на захвате
exclusive lockи ждет, пока предыдущий sync-вызов отпустит.
Это дает sync-компонентам гарантию: в один момент времени внутри них исполняется ровно один sync-вызов, даже если снаружи бомбардируют async-вызовами.
Что будет, если callee тоже async
-
При входе в async callee не захватывается
exclusive lockна инстанс. Поэтому второй (и третий, и N-й) async-вызов того же экспорта в тот же инстанс может стартовать параллельно. -
Каждый из этих
Taskисполняется кооперативно: пока один заблокировался, другой может продвигаться. -
Backpressure появляется только если callee явно его включил через
backpressure.set -
Завершение родительского
Taskтребует завершения (или отмены) всех егоSubtask
Reentrancy
Reentrancy это ситуация, когда в компонент пытаются войти повторно до завершения предыдущего вызова. Для обычного синхронного компонента такой сценарий невозможен. При входе в sync-export инстанс получает exclusive lock. Пока вызов не завершится, любой второй вход в тот же инстанс будет ждать освобождения lock. Именно поэтому в WASI 0.2 внутри одного инстанса фактически существовал только один активный вызов.
С появлением native async ситуация меняется. Если экспорт объявлен как async, вызов может достичь suspend-point и вернуть управление runtime до завершения работы. В этот момент инстанс больше не считается занятым синхронным вызовом, и runtime получает возможность запускать другие задачи внутри того же инстанса.
Таким образом, для async-компонента необходимо исходить из предположения, что одновременно могут существовать несколько активных Task внутри одного инстанса. Конкурентность становится нормальным режимом работы, а не исключением.
Completion-based модель
В WASI 0.2 действовала readiness-модель: pollable сигнализировал «можно читать», а сам факт завершения или ошибки caller узнавал только при следующем read. Если читатель останавливался раньше, отличить штатное закрытие stream от терминальной ошибки было невозможно.
В WASI 0.3 модель сменилась на completion-based: runtime сообщает не «источник готов», а «операция завершилась, вот результат». На уровне ABI это выражается так:
-
future<T>резолвится ровно одним значением — успехом с данными или ошибкой. Промежуточного состояния «попробуй прочитать снова» не существует. -
stream<T>в WIT 0.3 всегда сопровождается отдельнымfuture<result<_, error-code>>, который резолвится один раз и фиксирует итог стрима (успешное закрытие или ошибка) независимо от того, сколько было прочитано:
// WASI 0.2read-via-stream: func() -> result<input-stream, error-code>; // WASI 0.3read-via-stream: func() -> tuple<stream<u8>, future<result<_, error-code>>>;
Structured Concurrency
Component Model использует structured concurrency: каждая асинхронная операция принадлежит некоторому родительскому Task, образуя дерево выполнения. Главное правило: родительский Task не может завершиться, пока не завершились все принадлежащие ему Subtask.
Это свойство дает три гарантии:
-
отсутствуют «потерянные» фоновые задачи;
-
отмена автоматически распространяется вниз по дереву вызовов;
-
освобождение ресурсов происходит детерминированно.
Если caller отменяет handle(request), runtime обязан отменить все незавершённые дочерние Subtask перед переводом родительского Task в терминальное состояние. Аналогично, если родитель завершился ошибкой, все еще работающие дочерние операции должны быть завершены или отменены.
Cancellation
Cancellation в Component Model кооперативная и структурная. Принудительно прервать выполнение wasm-кода нельзя: runtime лишь доставляет задаче сигнал «отменено», а callee обязан корректно его обработать.
Кто кого отменяет:
-
subtask.cancel— caller отменяет конкретный async-import, который он ранее запустил. Это нормальный путь: caller владеетSubtaskв своей waitable-таблице и решает, что результат больше не нужен. -
task.cancel— callee добровольно завершает свой собственныйTaskотменой вместоtask.return. Используется, когда callee получил сигнал отмены сверху и не может (или не хочет) производить штатный результат.
Cancellation распространяется вниз по async call stack: отмена родительского Task обязывает отменить все его незавершенные Subtask. Завершение родителя невозможно, пока дочерние не перешли в терминальное состояние (resolved/cancelled). Cancellation не пробрасывается вверх как ошибка. Для caller отмененный subtask это легитимный исход, а не trap.
Где WASM может приостановиться
Очень много раз выше мы оперировали термином «приостановки» (suspend). Что значит «приостановиться»? Это значит достичь suspend-point в гостевом коде на уровне Canonical ABI:
-
Явные built-ins (
Taskсам инициирует suspend):-
task.wait— ожидание готовности любогоwaitableиз текущегоwaitable set. (например, ожиданиеasyncимпорта илиfuture, как разобрали выше) -
waitable-set.wait— то же, но с явной передачей конкретногоset. -
task.yield— добровольная отдача управления без ожидания конкретногоwaitable.
-
-
Неявные suspend points (создаются Canonical ABI вокруг операций):
-
Вход в sync-lifted экспорт при удержанном чужом
lockна инстанс (Taskвстает вSTARTINGподbackpressure). -
Вход в экспорт инстанса, где явно установлен
backpressure.set(true) -
Возврат из вызова, у которого ещё остались живые
subtask— structure concurrency требует дождаться их завершения, родительскийTaskприостанавливается до завершения/отмены последнегоsubtask.
-
При приостановке вызываемой функции управление немедленно возвращается вызывающей стороне с маркером «pending», задача продолжает исполнение конкурентно. Хост thread свободен.
Раскраска функций
В традиционных async-моделях способность функции приостанавливаться является свойством самой функции, поэтому suspend в глубине стека вынуждает делать async всю цепочку вызовов до точки входа (как в Kotlin, например). В Component Model способность приостанавливаться принадлежит не функции языка, а Task Canonical ABI. suspend сохраняет состояние Task и возвращает управление runtime независимо от того, сколько sync- и async-вызовов находится выше или ниже по стеку. Поэтому sync-компонент может вызывать async-компонент, async-компонент может вызывать sync-компонент, а композиция не требует распространения async через всю цепочку интерфейсов. Runtime берет на себя сохранение прогресса, планирование и возобновление выполнения.
Component Model не делает все функции async. Он делает приостановку свойством ABI, а не свойством стека вызовов языка. Именно поэтому исчезает необходимость раскрашивать всю цепочку вызовов.
В Component Model suspend относится не к функции языка, а к Task Canonical ABI.
Представим wit:
process: func(req: request) -> responsehttp-call: async func() -> http-response
И внутри реализации Rust:
fn process(req: Request) -> Response { let r = http_call(req); ...}
Что происходит при вызове async-import? http_call() может стать suspend, создается Subtask, текущий Task переходит в ожидание. Когда HTTP завершается, Task возобновляется и продолжает выполнение. В момент suspend рантайм не требует, чтобы весь путь до вершины был async. Он просто фиксирует состояние текущего Task и возвращает управление event loop. Когда событие приходит, тот же Task продолжается дальше.
То есть suspend происходит на уровне Component Model, а не на уровне языка.
Разница лишь в том, что в комбинации sync export -> async import хостовой поток не будет освобожден до тех пор, пока Task не завершится, в а комбинации async export -> async import экспорт тоже suspend и хостовой поток не будет заблокирован.
С точки зрения Component Model это совершенно легально.
Подробнее про сигнатуры
Рассмотрим три варианта объявления функций:
foo: async func(s: string) -> stringfoo: async func(s: string) -> future<string>foo: func(s: string) -> future<string>
-
future<T>отвечает на вопрос: когда будет доступно значение? -
asyncотвечает на вопрос: может ли сам вызов функции приостановиться?
foo: async func(s: string) -> string
Семантика: вызов функции сам может приостановиться. Результат: string но получение этого результата может занять время.
foo: func(s: string) -> future<string>
Семантика: создает объект: future и сразу возвращает его (Caller получает handle на future, он может скомбинировать с другими future). Заполнить future может хост или другой компонент (владелец writable end этого future)
foo: async func(s: string) -> future<string>
Семантика: сам вызов foo() может приостановиться, результатом вызова является future
Практические выводы
По спецификации Component Model async в WIT — это просто хинт для генератора биндинга, который сгенерирует export в идиоматичном для разных языков стиле (stackless или stackful). Такая функция может приостановиться, при приостановке вызываемой функции управление немедленно возвращается вызывающей стороне с маркером pending. Хост thread становится свободен (в отличие от sync режима). Более того, хост может вызвать handle тысячу раз параллельно на одном инстансе компонента (в отличие от sync режима).
Зачем нужен async export, если по существу WASM-компонент — это песочница, не имеющая доступа к I/O? Дело в том, что в I/O сценарии нам нужен импорт от хоста, который выполнит сетевой запрос. И если нам потребуется делать несколько независимых HTTP-вызовов внутри компонента, импорт тоже должен быть асинхронным. В таком случае, нам нужно воспользоваться async import, и «приостановка» на импорте должно транслироваться наружу через экспорт, иначе хост будет блокироваться на вызове нашего экспорта, и мы потеряем часть пропускной способности.
Приостановка вызываемой функции (когда управление возвращается вызывающей стороне) называется suspend points и они, как правило, генерируется генератором биндингов. В Rust c wit-bindgen, например, все suspend points спрятаны за .await. Компилятор Rust разрезает async-функцию на состояния именно на точках .await, а wit-bindgen в этих точках вставляет task.wait / waitable-set.wait. Поэтому в коде для нескольких независимых HTTP-вызовов:
let (a, b, c) = futures::try_join!( http_handle(req_a), http_handle(req_b), http_handle(req_c),)?;
Есть один suspend point — это .await внутри try_join!. Сами вызовы http_handle() до .await лишь создают три subtask и не приостанавливают Task.
В примере выше практически происходит следующее: host вызывает handle(), создаётся Task экспорта, а каждый вызов HTTP создаёт отдельный async import. Для каждого async import создаётся отдельный Subtask, и все три Subtask помещаются в waitable-set текущего Task. Когда Task достигает suspend-point внутри try_join!, управление возвращается host runtime, Wasmtime продолжает обслуживать другие задачи и другие инстансы. По мере завершения HTTP-запросов соответствующие Subtask переходят в completed, и когда завершается последний ожидаемый Subtask, runtime возобновляет исходный Task. По итогу выполняется объединение результатов и вызывается task.return.
Если вдруг внешний caller (хост) отменяет экспорт handle(request), runtime отменит и все N созданных subtask. Каждый из этих subtask перейдет в cancelled-состояние, соответствующие future<incoming-response> отрезолвятся как cancelled, буферы под ответы освободятся. Код в идиоматическом async режиме получит это как обычную отмену future, бойлерплейт прячут сгенерированные биндинги к языкам.
Важный нюанс: Async в Component Model не означает автоматическое распараллеливание по CPU. Async решает проблему ожидания внешних событий (HTTP, сокеты, файлы, таймеры). Пока задача ожидает I/O, runtime может выполнять другую задачу. Если код внутри компонента выполняет тяжелые вычисления без suspend-point, то такой код продолжает удерживать исполнение независимо от того, объявлен экспорт как async или нет.
Wasmtime
Со стороны реализации Component Model в Wasmtime тоже существуют абстракции для асинхронной работы. Но предпосылки к их появлению и скоуп решаемых задач немного отличается от WASI.
Жизнь до WASI 0.3
Возьмем наш предыдущий пример кода WASM-компонента:
let (a, b, c) = futures::try_join!( http_handle(req_a), http_handle(req_b), http_handle(req_c),)?;
Хост с Wasmtime вызывает компонент func.call(), в какой-то момент компонент делает import для реального HTTP-вызова. Проблема: помимо того, что WASM-компонент сам заблокирован (остановил свое выполнение на границе ABI) на ожидании первого импорта (ведь WASI 0.3 еще не появился), но и сам Wasmtime вынужден блокироваться (вот здесь блокировка настоящая, именно host thread) на выполнении HTTP-вызова. Хост thread занят, хотя мог выполнять полезную работу.
Решением стало появление набора *_async() функций. Фактически — это решение проблемы на стороне Wasmtime, благодаря которой появляется связка async host + sync guest.
Например, для решения задачи выше используется функция call_async().
При ее вызове Wasmtime аллоцирует отдельный нативный стек (fiber stack) и запускает на нём wasm-код. Компонент работает синхронно, он вообще не знает, что живёт на fiber (stackful coroutine). Когда компонент зовёт host-функцию, делающую I/O, и эта функция возвращает Poll::Pending, Wasmtime делает stack_switch обратно на стек хоста. Хост возвращает в Tokio Pending. Tokio может заниматься другими задачами. Когда I/O готово, Tokio снова поллит future, а Wasmtime делает stack_switch назад на fiber, компонент продолжает с той же инструкции, где остановился.
Работает это благодаря крейту wasmtime-fiber внутри Wasmtime. По названию видно, что был выбран stackful подход для реализации корутин, хотя штатная языковая фича Rust в виде async/await — это stackless.
Разработчики Wasmtime сознательно не стали использовать stackless-подход Rust для приостановки wasm-кода. Причина — невозможно превратить произвольный, уже скомпилированный wasm-байткод в Rust state machine (а все stackless корутины компилируются в state machine): трансформация в state machine работает только на уровне исходника, а у Wasmtime на входе не Rust-исходник, а готовый wasm. Поэтому Wasmtime аллоцирует отдельный нативный стек, исполняет wasm на нём и переключает стеки ассемблерной процедурой.
Параллельно существует языковая фича самого WebAssembly — proposal stack-switching. Он добавляет в wasm новые инструкции (cont.new, resume, suspend, switch) и тип continuation, чтобы wasm-гость сам мог создавать дополнительные стеки (при необходимости) и переключаться между ними без участия хоста. Это нужно для эффективной реализации корутин, генераторов, горутин Go и т.п. внутри одного wasm-инстанса (это мы обсудили выше).
Историческая справка
Именно идеями wasmtime-fiber (и stack switching proposal) вдохновлен WASI 0.3
Набор *_async() функций:
-
call_async()— вызвать wasm-функцию -
instantiate_async()— асинхронно создать инстанс -
new_async()— создать async host функцию -
wrap_async()— типизированная обертка надnew_async() -
func_wrap_async()— регистрация async host функции вLinker(чтобы Wasmtime смог найти реализацию, когда компонент вызовет эту функцию)
Важно учесть, что Wasmtime использует *_async API только если Config::async_support(true) и Store создан как async-store. Тогда весь путь обязан использовать async-варианты API.
Жизнь после WASI 0.3
С появлением WASI 0.3 асинхронность стала частью самого Component Model. Если раньше async существовал только как внутренняя реализация хоста (call_async(), fibers, stack switching внутри Wasmtime), то теперь он входит в ABI компонентов. Компонент может импортировать и экспортировать асинхронные функции напрямую.
В Wasmtime есть полноценная поддержка Async ABI. Все структуры, enum, функции и т.д., необходимые для реализации WASI 0.3 помечены лейблом component-model-async в документации, например, функция call_concurrent()
Возьмем тот же пример:
let (a, b, c) = futures::try_join!( http_handle(req_a), http_handle(req_b), http_handle(req_c),)?;
Теперь http_handle() может быть настоящим async-импортом на уровне Component Model. При выполнении await компонент возвращает управление рантайму через Async ABI. Wasmtime больше не обязан удерживать отдельный fiber и выполнять stack switching для каждого ожидающего вызова. Вместо этого состояние асинхронной операции представлено явно в ABI через специальные async-примитивы Component Model.
Главное отличие от WASI 0.2 состоит в том, что асинхронность стала понятна не только хосту, но и самим компонентам. Компоненты могут вызывать друг друга через async-интерфейсы без промежуточных pollable, ручного проброса событий готовности и локальных event loop внутри каждого компонента. Рантайм получает возможность координировать ожидание сразу между всеми участниками цепочки вызовов.
async стал частью ABI Component Model, а не отдельной _async надстройкой Wasmtime. Поэтому появились не просто новые функции, а целый набор новых сущностей: task, future, stream, _concurrent() и т.д.
Набор API для Async ABI (component-model-async):
-
call_concurrent()— асинхронно вызвать экспорт компонента как отдельную guestTask. Может выполняться конкурентно с другими вызовами того же instance, в отличие отcallиcall_async, которые требуют эксклюзивного доступа к store до завершения вызова.call_async()не создает guestTaskвнутри component instance. -
start_call_concurrent()— запустить guestTaskбез ожидания результата -
finish_call_concurrent()— дождаться завершения ранее запущенной guestTask -
func_wrap_concurrent()— зарегистрировать async host-функцию по модели WASI 0.3 -
run_concurrent()— создать область выполнения, внутри которой guestTaskмогут прогрессировать -
spawn()— запустить дополнительную hostTask, связанную с текущим компонентом (не Canonical ABITask, аTaskWasmtimescheduler.)
call_async() и Async ABI не являются взаимоисключающими механизмами. Первый остается, чтобы решать задачу неблокирующего исполнения синхронного wasm-кода в Wasmtime.
Wasmtime C-API
Если вы хотите управлять жизненным циклом Wasmtime из Java, то готовых биндингов, к сожалению, на данный момент нет. Но есть прекрасный выход — использовать Wasmtime C-API и реализовать бинидинги самостоятельно с помощью Project Panama 🙂
Но есть проблема: Wasmtime-C-API существенно отстает от Rust API в части Async Component Model. В публичной документации C API присутствует только старый async-механизм на базе fibers и call_future.
Я создал Issue для этой проблемы https://github.com/bytecodealliance/wasmtime/issues/13705
Сейчас в C API есть функция для активации async режима: wasmtime_config_async_support_set(config, true). После этой активации вместо wasmtime_func_call() используется wasmtime_func_call_async(), которая возвращает wasmtime_call_future_t*, а не результат сразу. Дальше ты сам поллишь future wasmtime_call_future_poll().
Самое неприятное — для одного Store разрешён только один живой wasmtime_call_future_t одновременно.
Демонстрация
Я опубликовал Rust-проект, где явно реализованы разные модели асинхронности для одной и той же задачи, а также написан мини-бенчмарк, показывающий разные результаты по пропускной способности и потреблению ресурсов при использовании разных подходов к асинхронным вызовам Wasmtime. Явные плюсы WASI 0.3, заметные по результатам исследования и тестирования:
-
1 инстанс == N параллельных вызовов (на просторах интернета заявляется ~50-200 КБ на задачу в WASI 0.3 против ~2-8 МБ на полный клон instance в WASI 0.2 — улучшение примерно в 10-40 раз)
-
Внутри одного инстанса можно запрашивать N импортов асинхронно, что сделает нашу систему production ready. Память при 1000 одновременных HTTP-соединений : на просторах интернета заявляется ~150-300 МБ в WASI 0.3 против 2-8 ГБ в WASI 0.2 — улучшение примерно в 10-25 раз (модель shared instance устраняет необходимость дублировать линейную память и состояние для каждого соединения)
-
Language agnostic подход, можно выбирать любой язык в качестве frontend для компиляции
Ссылка на GitHub (добро пожаловать!) https://github.com/rudikone/wasm-example
Ссылки
-
https://github.com/WebAssembly/component-model/tree/main/design/mvp
-
https://github.com/WebAssembly/stack-switching/blob/main/proposals/stack-switching/Explainer.md
-
https://blog.codercops.com/blog/wasi-0-3-native-async-io-webassembly-2026
-
https://github.com/bytecodealliance/wasmtime/blob/main/tests/all/component_model/async.rs#L517
-
https://github.com/bytecodealliance/wasmtime/blob/main/tests/all/component_model/async.rs#L608
-
https://github.com/bytecodealliance/wasmtime/blob/main/tests/all/component_model/async.rs#L834
ссылка на оригинал статьи https://habr.com/ru/articles/1055048/