【V8引擎blog翻译-194】WebAssembly JavaScript Promise Integration API简介

995 阅读11分钟

发布时间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来编译获取的结果并从中创建一个实例化的模块。fetchWebAssembly.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

  1. 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应用程序可见。↩︎

  2. For the technically curious, see the WebAssembly proposal for JSPI and the V8 stack switching design portfolio↩︎

    对于技术上的好奇,请参阅JSPI的WebAssembly提案V8堆栈交换设计组合↩︎

  3. 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”-然后重新启动。↩︎

  4. Note: we include the complete program below, in Appendix A. ↩︎

    注:我们在下面的附录A中包含了完整的程序。↩︎

  5. Note: you need a version of Emscripten that is ≥ 3.1.61. ↩︎

    注意:您需要的Emscripten版本≥ 3.1.61。↩︎

  6. We do not need this flag for our specific example, but you would likely need it for anything bigger. ↩︎

    对于我们的特定示例,我们不需要此标志,但对于任何更大的示例,您可能需要它。↩︎

  7. The complete program is shown in Appendix B. ↩︎

    完整程序见附录B。↩︎

由弗朗西斯麦凯布,蒂博米肖,伊利亚雷兹沃夫,布伦丹达尔发布。