Перейти к основному содержимому

Представляем WebAssembly JavaScript Promise Integration API

· 13 мин. чтения
Фрэнсис МакКейб, Тибо Мишо, Илья Резвов, Брэндан Даль

API JavaScript Promise Integration (JSPI) позволяет приложениям WebAssembly, которые были написаны с расчётом на синхронный доступ к внешней функциональности, работать корректно в среде, где функциональность фактически является асинхронной.

В этом документе описываются основные возможности JSPI API, способы его использования, разработка программного обеспечения, а также приводятся примеры для изучения.

Для чего нужен ‘JSPI’?

Асинхронные API работают, разделяя инициацию операции от её завершения; при этом завершение происходит спустя некоторое время после начала. Самое важное, что приложение продолжает выполнение после запуска операции и получает уведомление, когда она завершается.

Например, используя API fetch, веб-приложения могут получить доступ к содержимому, связанному с URL; однако, функция fetch не возвращает результаты непосредственно, вместо этого она возвращает объект Promise. Связь между ответом fetch и исходным запросом восстанавливается путём присоединения обратного вызова к этому объекту Promise. Функция обратного вызова может проверить ответ и собрать данные (если они доступны).

Во многих случаях приложения на C/C++ (и многих других языках программирования) изначально пишутся с использованием синхронного API. Например, функция Posix read не завершается, пока операция ввода-вывода не будет выполнена до конца: функция read блокирует выполнение до завершения операции чтения.

Однако, блокировать основной поток браузера недопустимо, и многие среды не поддерживают синхронное программирование. В результате возникает несоответствие между желанием разработчика приложения использовать простой API и требованиями широкой экосистемы, где ввод-вывод должен выполнять асинхронный код. Это особенно актуально для существующих устаревших приложений, которые сложно и дорого портировать.

JSPI — это API, который устраняет разрыв между синхронными приложениями и асинхронными веб-API. Он работает, перехватывая объекты Promise, возвращаемые асинхронными функциями веб-API, и приостанавливая приложение WebAssembly. Когда асинхронная операция ввода-вывода завершается, приложение WebAssembly возобновляется. Это позволяет приложению WebAssembly использовать линейный код для выполнения асинхронных операций и обработки их результатов.

Важно отметить, что использование JSPI требует минимальных изменений в самом приложении WebAssembly.

Как работает JSPI?

JSPI работает, перехватывая объект Promise, возвращённый вызовами JavaScript, и приостанавливая основную логику приложения WebAssembly. К этому объекту Promise прикрепляется обратный вызов, который возобновляет приостановленный код WebAssembly, когда его вызовет обработчик задач браузера.

Кроме того, экспорт WebAssembly реорганизуется так, чтобы возвращать объект Promise — вместо исходного значения, возвращаемого экспортом. Этот объект Promise становится значением, возвращаемым приложением WebAssembly: когда код WebAssembly приостанавливается,1 экспортный объект Promise возвращается как значение вызова в WebAssembly.

Экспортный объект Promise разрешается, когда исходный вызов завершён: если исходная функция WebAssembly возвращает обычное значение, экспортный объект Promise разрешается с этим значением (преобразованным в JavaScript-объект); если выбрасывается исключение, то экспортный объект Promise отклоняется.

Обёртка для импорта и экспорта

Это обеспечивается обёрткой импорта и экспорта в процессе инстанцирования модуля WebAssembly. Обёртки функций добавляют поведение при приостановке к нормальным асинхронным импортам и направляют приостановки к обратным вызовам объектов Promise.

Нет необходимости оборачивать все экспорты и импорты модуля WebAssembly. Некоторые экспорты, чьи пути выполнения не предполагают вызовов асинхронных API, лучше оставить не обёрнутыми. Точно так же, не все импорты модуля WebAssembly относятся к функциям асинхронных API; такие импорты тоже не следует оборачивать.

Конечно, существует значительное количество внутренних механизмов, которые позволяют этому происходить,2 но ни язык JavaScript, ни WebAssembly сами по себе не изменяются с помощью JSPI. Его операции ограничены границей между JavaScript и WebAssembly.

С точки зрения разработчика веб-приложений, результат представляет собой блок кода, который участвует в мире JavaScript асинхронных функций и объектов Promise аналогичным образом, как работают другие асинхронные функции, написанные на JavaScript. С точки зрения разработчика WebAssembly, это позволяет создавать приложения с использованием синхронных API и при этом участвовать в асинхронной экосистеме веба.

Ожидаемая производительность

Поскольку механизмы, используемые при приостановке и возобновлении модулей WebAssembly, в основном работают за постоянное время, мы не ожидаем высоких затрат при использовании JSPI — особенно по сравнению с другими подходами, основанными на преобразованиях.

Для распространения объекта Promise, возвращаемого асинхронным API вызовом, в WebAssembly потребуется постоянное количество работы. Аналогично, когда Promise разрешается, приложение WebAssembly можно возобновить с постоянным временным накладным расходом.

Однако, как и в случае других API Promise в браузере, всякий раз, когда приложение WebAssembly приостановлено, оно не будет «пробуждено» снова, кроме как задачей браузера. Это требует, чтобы выполнение JavaScript-кода, инициировавшего вычисление WebAssembly, само вернулось в браузер.

Можно ли использовать JSPI для приостановки JavaScript программ?

JavaScript уже имеет хорошо развитый механизм для представления асинхронных вычислений: объект Promise и синтаксис функции async. JSPI предназначен для хорошей интеграции с этим механизмом, а не для его замены.

Как я могу использовать JSPI сегодня?

JSPI в настоящее время стандартизируется рабочей группой W3C WebAssembly. На момент написания он находится на этапе 3 процесса стандартизации, и мы ожидаем полной стандартизации до конца 2024 года.

JSPI доступен для Chrome на Linux, MacOS, Windows и ChromeOS, на платформах Intel и Arm, как 64-битных, так и 32-битных.3

JSPI можно использовать двумя способами уже сегодня: через origin trial и локально через флаг Chrome. Чтобы протестировать его локально, перейдите на chrome://flags в Chrome, найдите «Experimental WebAssembly JavaScript Promise Integration (JSPI)» и установите флажок. Перезапустите браузер в соответствии с предложением, чтобы изменения вступили в силу.

Рекомендуется использовать как минимум версию 126.0.6478.26, чтобы получить последние обновления API. Мы советуем использовать Dev-канал для обеспечения применения любых обновлений стабильности. Кроме того, если вы хотите использовать Emscripten для генерации WebAssembly (что мы рекомендуем), вам следует использовать версию не ниже 3.1.61.

После включения JSPI, вы сможете запускать скрипты, его использующие. Мы покажем, как можно использовать Emscripten для генерации модуля WebAssembly на C/C++ с использованием JSPI. Если ваше приложение использует другой язык и Emscripten не нужен, рекомендуем изучить, как работает API, по предложению.

Ограничения

Реализация JSPI в Chrome уже поддерживает типичные случаи использования. Однако он все еще считается экспериментальным, поэтому следует знать о некоторых ограничениях:

  • Требуется использование флага командной строки или участие в origin trial.
  • Каждый вызов JSPI экспорта выполняется на стеке фиксированного размера.
  • Поддержка отладки довольно минимальна. В частности, может быть трудно увидеть различные события в панели инструментов разработчика. Расширение поддержки отладки JSPI приложений находится в планах.

Небольшая демонстрация

Чтобы увидеть, как все это работает, попробуем простой пример. Эта программа на C вычисляет числа Фибоначчи крайне неэффективным способом: запрашивая JavaScript для выполнения сложения, причем ещё хуже — используя объекты Promise для этого:4

long promiseFib(long x) {
if (x == 0)
return 0;
if (x == 1)
return 1;
return promiseAdd(promiseFib(x - 1), promiseFib(x - 2));
}
// обещание сложения
EM_ASYNC_JS(long, promiseAdd, (long x, long y), {
return Promise.resolve(x+y);
});

Функция promiseFib сама по себе является простой рекурсивной версией функции Фибоначчи. Интересная часть (с нашей точки зрения) — это определение promiseAdd, которое выполняет сложение двух частей Фибоначчи, используя JSPI!.

Мы используем макрос Emscripten EM_ASYNC_JS, чтобы записать функцию promiseFib как JavaScript функцию в теле нашей программы на C. Поскольку сложение обычно не включает Promise в JavaScript, нам приходится принудительно создавать Promise.

Макрос EM_ASYNC_JS генерирует весь необходимый связующий код, чтобы мы могли использовать JSPI для доступа к результату Promise как к обычной функции.

Чтобы скомпилировать нашу небольшую демонстрацию, мы используем компилятор Emscripten emcc:5

emcc -O3 badfib.c -o b.html -s JSPI

Это компилирует нашу программу, создавая загружаемый HTML файл (b.html). Самая заметная опция командной строки здесь — -s JSPI. Она активирует генерацию кода, который использует JSPI для взаимодействия с JavaScript импортами, возвращающими Promise.

Если загрузить сгенерированный файл b.html в Chrome, то вы увидите приближённый результат:

fib(0) 0μs 0μs 0μs
fib(1) 0μs 0μs 0μs
fib(2) 0μs 0μs 3μs
fib(3) 0μs 0μs 4μs

fib(15) 0μs 13μs 1225μs

Это просто список первых 15 чисел Фибоначчи с указанием среднего времени в микросекундах, затраченного на вычисление одного числа Фибоначчи. Три значения времени на каждой строке относятся к времени, затраченному на чистое вычисление в WebAssembly, смешанное вычисление в JavaScript/WebAssembly, а третье число показывает время для приостанавливающей версии вычисления.

Обратите внимание, что fib(2) - самое маленькое вычисление, которое включает доступ к Promise, и к моменту вычисления fib(15) было выполнено около 1000 вызовов promiseAdd. Это предполагает, что фактическая стоимость функции с JSPI составляет примерно 1μs — существенно выше, чем просто сложение двух целых чисел, но значительно меньше миллисекунд, обычно требуемых для доступа к функции внешнего ввода-вывода.

Использование JSPI для ленивой загрузки кода

В следующем примере мы рассмотрим довольно неожиданное использование JSPI: динамическую загрузку кода. Идея заключается в fetch модуля, содержащего необходимый код, но с отсрочкой до момента, когда нужная функция будет вызвана впервые.

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

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

Модуль, который мы собираемся загрузить, довольно простой, он содержит функцию, возвращающую 42:

// Это простой провайдер "сорока двух"
#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE long provide42(){
return 42l;
}

Этот код находится в файле под названием p42.c и компилируется с использованием Emscripten без создания «дополнительных» модулей:

emcc p42.c -o p42.wasm --no-entry -Wl,--import-memory

Префикс EMSCRIPTEN_KEEPALIVE — это макрос Emscripten, который гарантирует, что функция provide42 не будет удалена, даже если она не используется в коде. В результате получается модуль WebAssembly, содержащий функцию, которую мы хотим загружать динамически.

Флаг -Wl,--import-memory, который мы добавили к построению p42.c, гарантирует доступ к той же памяти, что и у основного модуля.6

Для динамической загрузки кода мы используем стандартный API WebAssembly.instantiateStreaming:

WebAssembly.instantiateStreaming(fetch('p42.wasm'));

Это выражение использует fetch для поиска скомпилированного Wasm-модуля, а WebAssembly.instantiateStreaming для компиляции результата fetch и создания из него экземпляра модуля. Как fetch, так и WebAssembly.instantiateStreaming возвращают Promises, поэтому мы не можем просто получить результат и извлечь нужную функцию. Вместо этого мы оборачиваем это в стиль импорта JSPI с использованием макроса EM_ASYNC_JS:

EM_ASYNC_JS(fooFun, resolveFun, (), {
console.log('loading promise42');
LoadedModule = (await WebAssembly.instantiateStreaming(fetch('p42.wasm'))).instance;
return addFunction(LoadedModule.exports['provide42']);
});

Обратите внимание на вызов console.log, мы будем использовать его, чтобы убедиться в правильности нашей логики.

addFunction является частью API Emscripten, но чтобы убедиться, что он доступен нам во время выполнения, мы должны сообщить emcc, что это необходимая зависимость. Мы делаем это в следующей строке:

EM_JS_DEPS(funDeps, "$addFunction")

В ситуации, когда мы хотим динамически загружать код, мы хотим убедиться, что не загружаем его слишком часто; в данном случае мы хотим убедиться, что последующие вызовы provide42 не будут вызывать повторную загрузку. У C есть простая функция, которую мы можем использовать для этого: мы не вызываем provide42 напрямую, а делаем это через trampolines, которые вызовут загрузку функции, а затем перед её реальным вызовом изменят себя. Мы можем сделать это с помощью подходящего указателя на функцию:

extern fooFun get42;

long stub(){
get42 = resolveFun();
return get42();
}

fooFun get42 = stub;

С точки зрения остальной части программы, функция, которую мы хотим вызвать, называется get42. Её первоначальная реализация осуществляется через stub, который вызывает resolveFun, чтобы фактически загрузить функцию. После успешной загрузки мы изменяем get42 на вызов загруженной функции - и вызываем её.

Наша основная функция вызывает get42 дважды:7

int main() {
printf("first call p42() = %ld\n", get42());
printf("second call = %ld\n", get42());
}

Результат выполнения этого в браузере выглядит как лог:

загрузка promise42
первый вызов p42() = 42
второй вызов = 42

Заметьте, что строка загрузка promise42 появляется только один раз, тогда как get42 фактически вызывается дважды.

Этот пример демонстрирует, что JSPI можно использовать неожиданными способами: динамическая загрузка кода кажется далекой от создания промисов. Более того, существуют другие способы динамической связи модулей WebAssembly; это не предназначено быть окончательным решением этой задачи.

Мы с нетерпением ждем, чтобы увидеть, что вы сможете сделать с этой новой функцией! Присоединяйтесь к обсуждению в группе сообщества W3C WebAssembly repo.

Приложение A: Полный список badfib

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <emscripten.h>

typedef long (testFun)(long, int);

#define microSeconds (1000000)

long add(long x, long y) {
return x + y;
}

// Попросить JS выполнить сложение
EM_JS(long, jsAdd, (long x, long y), {
return x + y;
});

// обещание сложения
EM_ASYNC_JS(long, promiseAdd, (long x, long y), {
return Promise.resolve(x+y);
});

__attribute__((noinline))
long localFib(long x) {
if (x==0)
return 0;
if (x==1)
return 1;
return add(localFib(x - 1), localFib(x - 2));
}

__attribute__((noinline))
long jsFib(long x) {
if (x==0)
return 0;
if (x==1)
return 1;
return jsAdd(jsFib(x - 1), jsFib(x - 2));
}

__attribute__((noinline))
long promiseFib(long x) {
if (x==0)
return 0;
if (x==1)
return 1;
return promiseAdd(promiseFib(x - 1), promiseFib(x - 2));
}

long runLocal(long x, int count) {
long temp = 0;
for(int ix = 0; ix < count; ix++)
temp += localFib(x);
return temp / count;
}

long runJs(long x,int count) {
long temp = 0;
for(int ix = 0; ix < count; ix++)
temp += jsFib(x);
return temp / count;
}

long runPromise(long x, int count) {
long temp = 0;
for(int ix = 0; ix < count; ix++)
temp += promiseFib(x);
return temp / count;
}

double runTest(testFun test, int limit, int count){
clock_t start = clock();
test(limit, count);
clock_t stop = clock();
return ((double)(stop - start)) / CLOCKS_PER_SEC;
}

void runTestSequence(int step, int limit, int count) {
for (int ix = 0; ix <= limit; ix += step){
double light = (runTest(runLocal, ix, count) / count) * microSeconds;
double jsTime = (runTest(runJs, ix, count) / count) * microSeconds;
double promiseTime = (runTest(runPromise, ix, count) / count) * microSeconds;
printf("fib(%d) %gμs %gμs %gμs %gμs\n",ix, light, jsTime, promiseTime, (promiseTime - jsTime));
}
}

EMSCRIPTEN_KEEPALIVE int main() {
int step = 1;
int limit = 15;
int count = 1000;
runTestSequence(step, limit, count);
return 0;
}

Приложение B: Список u42.c и p42.c

Программный код u42.c на C представляет основную часть нашего примера динамической загрузки:

#include <stdio.h>
#include <emscripten.h>

typedef long (*fooFun)();

// обещание функции
EM_ASYNC_JS(fooFun, resolveFun, (), {
console.log('загрузка promise42');
LoadedModule = (await WebAssembly.instantiateStreaming(fetch('p42.wasm'))).instance;
return addFunction(LoadedModule.exports['provide42']);
});

EM_JS_DEPS(funDeps, "$addFunction")

extern fooFun get42;

long stub() {
get42 = resolveFun();
return get42();
}

fooFun get42 = stub;

int main() {
printf("первый вызов p42() = %ld\n", get42());
printf("второй вызов = %ld\n", get42());
}

Программный код p42.c - это модуль, загружаемый динамически.

#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE long provide42() {
return 42l;
}

Примечания

Footnotes

  1. Если приложение WebAssembly приостанавливается более одного раза, последующие приостановки будут возвращать управление в цикл задач браузера и не будут напрямую видны веб-приложению.

  2. Для технически любознательных, см. предложение WebAssembly для JSPI и портфолио дизайна переключения стека V8.

  3. JSPI также доступен в Firefox nightly: включите "javascript.options.wasm_js_promise_integration" в панели about:config — и перезапустите браузер.

  4. Примечание: полный программный код представлен ниже, в Приложении A.

  5. Примечание: вам потребуется версия Emscripten ≥ 3.1.61.

  6. Нам не нужен этот флаг для нашего конкретного примера, но он, вероятно, понадобится для чего-то большего.

  7. Полный программный код показан в Приложении B.