Представляем WebAssembly JavaScript Promise Integration API
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
-
Если приложение WebAssembly приостанавливается более одного раза, последующие приостановки будут возвращать управление в цикл задач браузера и не будут напрямую видны веб-приложению. ↩
-
Для технически любознательных, см. предложение WebAssembly для JSPI и портфолио дизайна переключения стека V8. ↩
-
JSPI также доступен в Firefox nightly: включите "
javascript.options.wasm_js_promise_integration
" в панели about:config — и перезапустите браузер. ↩ -
Примечание: полный программный код представлен ниже, в Приложении A. ↩
-
Примечание: вам потребуется версия Emscripten ≥ 3.1.61. ↩
-
Нам не нужен этот флаг для нашего конкретного примера, но он, вероятно, понадобится для чего-то большего. ↩
-
Полный программный код показан в Приложении B. ↩