发布时间July 1, 2024·标记为WebAssembly
JavaScript Promise Integration(JSPI)API允许假定同步访问外部功能而编写的WebAssembly应用程序在功能实际上是异步的环境中顺利运行。
本文概述了JSPI API的核心功能是什么,如何访问它,如何为它开发软件,并提供了一些示例来尝试。
“JSPI”是做什么的?
异步API通过将操作的启动与其解析分离来操作;后者在第一个之后一段时间出现。最重要的是,应用程序在启动操作后继续执行;然后在操作完成时得到通知。
例如,使用fetch API,Web应用程序可以访问与URL相关联的内容;然而,fetch函数并不直接返回获取的结果;而是返回Promise对象。fetch响应和原始请求之间的连接通过将回调附加到该Promise对象来重新建立。回调函数可以检查响应并收集数据(当然,如果它在那里)。
在许多情况下,C/C++(以及许多其他语言)应用程序最初是针对同步API编写的。例如,Posix read函数在I/O操作完成之前不会完成:read函数在读取完成之前会阻塞。
但是,不允许阻止浏览器的主线程;而且许多环境不支持同步编程。结果是应用程序员对简单易用的API的需求与更广泛的生态系统(需要使用异步代码精心制作I/O)之间的不匹配。对于移植成本高昂的现有遗留应用程序来说,这尤其是一个问题。
JSPI是一种API,它弥补了同步应用程序和异步Web API之间的差距。它通过拦截异步Web API函数返回的Promise对象并挂起WebAssembly应用程序来工作。当异步I/O操作完成时,WebAssembly应用程序将恢复。这允许WebAssembly应用程序使用直线代码来执行异步操作并处理其结果。
重要的是,使用JSPI只需要对WebAssembly应用程序本身进行很少的更改。
JSPI是如何工作的?
JSPI的工作原理是拦截调用JavaScript返回的Promise对象,并挂起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之间的边界。
从Web应用程序开发人员的角度来看,结果是一个代码体,它以类似于用JavaScript编写的其他JavaScript函数工作的方式参与到JavaScript的JavaScript函数和Promises世界中。从WebAssembly开发人员的角度来看,这允许他们使用同步API来制作应用程序,同时参与Web的异步生态系统。
预期性能
因为挂起和恢复WebAssembly模块时使用的机制本质上是恒定时间的,所以我们不认为使用JSPI会有很高的成本-特别是与其他基于转换的方法相比。
但是,与浏览器中的其他Promise风格的API一样,WebAssembly应用程序挂起时,除了浏览器的任务运行器之外,它不会再次“唤醒”。这要求启动WebAssembly计算本身的JavaScript代码的执行返回到浏览器。
我可以使用JSPI暂停JavaScript程序吗?
JavaScript已经有了一个开发良好的机制来表示异步计算:Promise对象和async函数符号。JSPI旨在与之很好地集成,但不是取代它。
今天如何使用JSPI?
JSPI目前正在由W3C WebAssembly WG进行标准化。截至撰写本文时,这是标准化过程的第三阶段,我们预计在2024年底之前实现完全标准化。
JSPI可用于Linux、MacOS、Windows和ChromeOS上的Chrome,以及Intel和Arm平台上的64位和32位。[3]第一章
如今,JSPI可以通过两种方式使用:通过原始试用和通过Chrome标志在本地使用。要在本地测试它,请转到Chrome中的chrome://flags,搜索“Experimental WebAssembly JavaScript Promise Integration(JSPI)”并选中该框。按照建议重新启动以使其生效。
您应该至少使用版本126.0.6478.26来获取最新版本的API。我们建议使用开发通道以确保应用任何稳定性更新。此外,如果你想使用Emscripten来生成WebAssembly(我们推荐),你应该使用至少是3.1.61的版本。
一旦启用,您应该能够运行使用JSPI的脚本。下面我们将展示如何使用Emscripten在C/C++中生成使用JSPI的WebAssembly模块。如果您的应用程序涉及不同的语言,例如不使用Emscripten,那么我们建议您查看API是如何工作的。
限制
JSPI的Chrome实现已经支持典型的用例。然而,它仍然被认为是实验性的,所以有一些限制需要注意:
- 需要使用命令行标志,或参与起源试验。
- 对JSPI导出的每个调用都在固定大小的堆栈上运行。
- 但支持是最小的。特别是,在Dev tools面板中可能很难看到发生的不同事件。为调试JSPI应用程序提供更丰富的支持已列入路线图。
一个小demo
要了解所有这些工作,让我们尝试一个简单的例子。这个C程序计算斐波那契的方式非常糟糕:要求JavaScript做加法,更糟糕的是使用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));
}
// promise an addition
EM_ASYNC_JS(long, promiseAdd, (long x, long y), {
return Promise.resolve(x+y);
});
函数本身是Fibonacci函数的一个简单递归版本。有趣的部分(从我们的角度来看)是promiseFib的定义,它使用JSPI!将两个Fibonacci半相加。
函数本身是Fibonacci函数的一个简单递归版本。有趣的部分(从我们的角度来看)是promiseFib的定义,它使用JSPI!将两个Fibonacci半相加。
EM_ASYNC_JS宏生成所有必要的粘合代码,以便我们可以使用JSPI访问Promise的结果,就像它是一个普通函数一样。
为了编译我们的小演示,我们使用Emscripten的emcc编译器:[5]
emcc -O3 badfib.c -o b.html -s JSPI
这将编译我们的程序,创建一个HTML文件(b.html)。这里最特殊的命令行选项是-s JSPI。这将调用选项来生成使用JSPI与返回Promises的JavaScript导入接口的代码。
如果您将生成的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)时,已经对promiseAdd进行了大约1000次调用。这表明,JSPI函数的实际成本约为1μs -比两个整数相加要高得多,但比访问外部I/O函数通常所需的毫秒要小得多。
使用JSPI延迟加载代码
在下一个例子中,我们将看看JSPI的一个令人惊讶的用法:动态加载代码。这个想法是fetch一个包含所需代码的模块,但要延迟到第一次调用所需的函数。
我们需要使用JSPI,因为像fetch这样的API本质上是异步的,但我们希望能够从应用程序中的任意位置调用它们-特别是从对尚不存在的函数的调用中间。
其核心思想是用存根(stub)替换动态加载的函数;这个存根首先加载丢失的函数代码,用加载的代码替换自己,然后用原始参数调用新加载的代码。对该函数的任何后续调用都直接转到加载的函数。这种策略允许一种本质上透明的方法来动态加载代码。
我们要加载的模块相当简单,它包含一个返回42的函数:
// This is a simple provider of forty-two
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE long provide42(){
return 42l;
}
它在一个名为p42.c的文件中,使用Emscripten编译,没有构建任何“extra”:
emcc p42.c -o p42.wasm --no-entry -Wl,--import-memory
EMSCRIPTEN_KEEPALIVE前缀是一个Emscripten宏,它确保函数provide42不会被删除,即使它没有在代码中使用。这将产生一个WebAssembly模块,其中包含我们想要动态加载的函数。
我们在构建-Wl,--import-memory时添加的p42.c标志是为了确保它可以访问主模块所拥有的相同内存。[6]
为了动态加载代码,我们使用标准的WebAssembly.instantiateStreaming API:
WebAssembly.instantiateStreaming(fetch('p42.wasm'));
这个表达式使用fetch来定位已编译的Wasm模块,使用WebAssembly.instantiateStreaming来编译获取的结果并从中创建一个实例化的模块。fetch和WebAssembly.instantiateStreaming都返回Promises;所以我们不能简单地访问结果并提取我们需要的函数。相反,我们使用EM_ASYNC_JS宏将其包装到JSPI风格的导入中:
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是Emscripten API的一部分,但为了确保它在运行时对我们可用,我们必须通知emcc它是必需的依赖项。我们在下面的行中这样做:
EM_JS_DEPS(funDeps, "$addFunction")
在我们想要动态加载代码的情况下,我们希望确保不会加载不必要的代码;在这种情况下,我们希望确保后续对provide42的调用不会触发重新加载。C有一个简单的特性,我们可以使用它:我们不直接调用provide42,而是通过一个蹦床来调用,这个蹦床会导致函数被加载,然后,就在实际调用函数之前,改变蹦床以绕过它本身。我们可以使用一个合适的函数指针:
extern fooFun get42;
long stub(){
get42 = resolveFun();
return get42();
}
fooFun get42 = stub;
从程序其余部分的角度来看,我们要调用的函数名为get42。它的初始实现是通过stub,它调用resolveFun来实际加载函数。成功加载后,我们将get42改为指向新加载的函数并调用它。
我们的main函数调用了get42两次:[7]
int main() {
printf("first call p42() = %ld\n", get42());
printf("second call = %ld\n", get42());
}
在浏览器中运行此命令的结果是一个类似于以下内容的日志:
loading promise42
first call p42() = 42
second call = 42
注意,行loading promise42只出现一次,而get42实际上被调用了两次。
这个例子演示了JSPI可以以一些意想不到的方式使用:动态加载代码似乎与创建promise相差甚远。此外,还有其他方法可以动态地将WebAssembly模块链接在一起;这并不是要代表该问题的最终解决方案。
我们非常期待看到您能利用这项新功能做些什么!加入W3C WebAssembly Community Grouprepo的讨论。
附录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;
}
// Ask JS to do the addition
EM_JS(long, jsAdd, (long x, long y), {
return x + y;
});
// promise an addition
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.cand和p42.c
u42.c C代码代表了我们动态加载示例的主要部分:
#include <stdio.h>
#include <emscripten.h>
typedef long (*fooFun)();
// promise a function
EM_ASYNC_JS(fooFun, resolveFun, (), {
console.log('loading 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("first call p42() = %ld\n", get42());
printf("second call = %ld\n", get42());
}
p42.c代码是动态加载的模块。
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE long provide42() {
return 42l;
}
Notes
-
If a WebAssembly application is suspended more than once, subsequent suspensions will return to the browser's event loop and will not be directly visible to the web application. ↩︎
如果WebAssembly应用程序挂起不止一次,后续挂起将返回浏览器的事件循环,并且不会直接对Web应用程序可见。↩︎
-
For the technically curious, see the WebAssembly proposal for JSPI and the V8 stack switching design portfolio. ↩︎
对于技术上的好奇,请参阅JSPI的WebAssembly提案和V8堆栈交换设计组合。↩︎
-
JSPI is also available in Firefox nightly: turn on "
javascript.options.wasm_js_promise_integration" in the about:config panel — and restart. ↩︎JSPI也可以在Firefox中每晚使用:在about:config面板中打开“
javascript.options.wasm_js_promise_integration”-然后重新启动。↩︎ -
Note: we include the complete program below, in Appendix A. ↩︎
注:我们在下面的附录A中包含了完整的程序。↩︎
-
Note: you need a version of Emscripten that is ≥ 3.1.61. ↩︎
注意:您需要的Emscripten版本≥ 3.1.61。↩︎
-
We do not need this flag for our specific example, but you would likely need it for anything bigger. ↩︎
对于我们的特定示例,我们不需要此标志,但对于任何更大的示例,您可能需要它。↩︎
-
The complete program is shown in Appendix B. ↩︎
完整程序见附录B。↩︎
由弗朗西斯麦凯布,蒂博米肖,伊利亚雷兹沃夫,布伦丹达尔发布。