介绍 WebAssembly JavaScript Promise 集成 API


JavaScript Promise Integration (JSPI) API 允许编写的 WebAssembly 应用程序假设对外部功能的访问是同步的,以便在许多所需功能是异步的环境中顺利运行。

本说明概述了 JSPI API 的核心功能是什么、如何访问它、如何为其开发软件并提供了一些尝试示例。

什么是“JSPI”?

JSPI 是一种在同步应用程序和异步 Web API 之间架起桥梁的 API。它通过在发出同步 API 调用时暂停应用程序并在异步 I/O 操作完成时恢复它来实现。至关重要的是,它只对应用程序本身进行了很少的更改。

Web 上的许多现代 API 本质上都是异步的。异步 API 通过将提供的功能分成两个独立的部分来运行:操作的启动及其解决;后者在第一个之后的某个时间出现。最重要的是,应用程序在启动操作后继续执行;然后在操作完成时收到通知。

例如,使用 fetch API 允许 Web 应用程序访问与 URL 关联的内容;但是,fetch 函数并不直接返回 fetch 的结果;相反,它返回一个承诺。通过将回调附加到该 Promise 来重新建立获取响应和原始请求之间的连接。回调函数可以检查响应并收集数据(当然如果有的话)。

正如经常记录的那样,直接使用 Promise 值非常困难。在 WebAssembly 应用程序的情况下,这个问题更加严重,因为它们不能直接操作 Promises。

JavaScript 的异步函数表示法在核心 API 之上提供了一层,显着减轻了 JavaScript 应用程序的负担。

另一方面,典型的 C/C++(和许多其他语言)应用程序最初通常是针对同步API 编写的。在这样的 API 中,应用程序将停止执行,直到操作完成。这种阻塞应用程序通常比异步感知的应用程序更容易编写。

但是,不允许阻塞浏览器的主线程;许多环境不支持同步编程。结果是应用程序程序员对简单易用的 API 的期望与需要使用异步代码构建 I/O 的更广泛的生态系统之间的不匹配。对于移植成本高昂的现有遗留应用程序来说,这尤其是一个问题。

JSPI 是如何工作的?

JSPI 的工作原理是拦截从异步 API 调用返回的 Promise,暂停 WebAssembly 应用程序的主要逻辑,并从用于进入 WebAssembly 的导出返回 Promise。当异步 API 完成时,WebAssembly 应用程序将恢复,以便它可以处理 API 调用的结果。

这是通过在 WebAssembly 模块实例化期间包装导入和导出来实现的。函数包装器将挂起行为添加到正常的异步导入并将挂起路由到 Promise 回调。

没有必要包装 WebAssembly 模块的所有导出和导入。一些执行路径不涉及调用异步 API 的导出最好不要包装。同样,并非所有 WebAssembly 模块的导入都是针对异步 API 函数的;这些进口也不应该包装。

当然,有大量的内部机制允许这种情况发生;[1]但是 JavaScript 语言和 WebAssembly 本身都没有被 JSPI 改变。它的操作仅限于 JavaScript 和 WebAssembly 之间的边界。

从 Web 应用程序开发人员的角度来看,结果是代码主体以类似于用 JavaScript 编写的其他异步函数工作的方式参与异步函数和 Promises 的 JavaScript 世界。从 WebAssembly 开发人员的角度来看,这使他们能够使用同步 API 制作应用程序,同时参与 Web 的异步生态系统。

预期表现

因为挂起和恢复 WebAssembly 模块时使用的机制本质上是恒定时间,所以我们预计使用 JSPI 的成本不会很高——尤其是与其他基于转换的方法相比。

将异步 API 调用返回的 Promise 传播到返回 Promise 的 WebAssembly 模块需要做大量工作。类似地,当一个 Promise 被 resolve 时,WebAssembly 应用程序可以以恒定时间开销立即恢复。

然而,与浏览器中的其他 Promise 风格的 API 一样,任何时候 WebAssembly 应用程序暂停时,除了浏览器的事件循环外,它都不会再次“唤醒”。这要求启动 WebAssembly 计算的 JavaScript 代码的执行本身返回到浏览器。

我可以使用 JSPI 来挂起 JavaScript 程序吗?

JavaScript 已经拥有完善的机制来表示异步计算:Promise 和async函数符号。JSPI 旨在与它很好地集成,而不是取代它。

下一步

JSPI 目前处于实验阶段——不应将其用于打算在生产中部署的 Web 应用程序。但是,这是标准的赛道努力;这意味着它最终将成为一个标准,我们希望它成为在所有主要浏览器中实施的标准。

这篇文章的其余部分将重点介绍如何访问 JSPI、如何开发使用它的代码以及一些要尝试的示例。

我今天如何使用它?

JSPI 正在 Intel x64 和 ARM 64 架构上开发。它适用于 Linux、macOS、Windows 和 ChromeOS。要在本地测试它,请转到chrome://flagsChrome,搜索“Experimental WebAssembly JavaScript Promise Integration (JSPI)”并选中该框。按照提示重启即可生效。

您应该至少使用版本110.0.5473.0(macOS) / 110.0.5469.0(Windows, Android) / 110.0.5478.4(Linux) 或 ChromeOS 来获取最新版本的 API。我们建议使用 Canary 通道以确保应用任何稳定性更新。此外,如果您希望使用 Emscripten 生成 WebAssembly(我们推荐),您应该使用至少3.1.28.

尚无法为最终用户启用该功能,只能通过启用此标志在本地进行测试。最终我们希望进行一次 Origin 试用,为想要选择加入的 Origin 启用此功能。

启用该标志后,您应该能够运行使用 JSPI 的脚本。下面我们展示了如何使用 Emscripten 在 C/C++ 中生成一个使用 JSPI 的 WebAssembly 模块。如果您的应用程序涉及不同的语言,例如不使用 Emscripten,那么我们建议您查看 API 的工作原理,您应该查看提案。

限制

JSPI 的 Chrome 实现应该已经支持典型的用例。然而,它是实验性的,因此需要注意一些限制:

一个小演示

要查看所有这些工作,让我们尝试一个简单的示例。这个 C 程序以一种非常糟糕的方式计算斐波那契数列:通过要求 JavaScript 执行加法,甚至更糟糕的是使用 JavaScript Promises 来执行此操作:[2]

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((k, r) => {
    setTimeout(() => {
      return k(x + y);
    }, 0);
  });
});

该promiseFib函数本身是 Fibonacci 函数的直接递归版本。有趣的部分(从我们的角度来看)是promiseAdd两个斐波那契半数相加的定义——使用 JSPI!。

我们使用EM_ASYNC_JSEmscripten 宏promiseFib在我们的 C 程序主体中将函数写为 JavaScript 函数。由于加法通常不涉及 JavaScript 中的 Promises,因此我们必须使用标准Promise.resolve函数来强制执行它。此外,我们必须隐藏setTimeout调用背后的算法,以确保引擎确实创建了一个涉及浏览器事件循环的 Promise。

该EM_ASYNC_JS宏生成所有必要的粘合代码,以便我们可以使用 JSPI 访问 Promise 的结果,就好像它是一个普通函数一样。

为了编译我们的小演示,我们使用 Emscripten 的emcc编译器:[3]

emcc -O3 badfib.c -o b.html -s ASYNCIFY=2

这会编译我们的程序,创建一个可加载的 HTML 文件 ( b.html)。这里最特别的命令行选项是-s ASYNCIFY=2. 这将调用生成代码的选项,该代码使用 JSPI 与返回 Promises 的 JavaScript 导入交互。[4]

如果将生成的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 — 明显高于仅将两个整数相加,但远小于访问外部 I/O 函数通常所需的毫秒数。

使用 JSPI 延迟加载代码

在下一个示例中,我们将了解 JSPI 的一个令人惊讶的用途:动态加载代码。这个想法是fetch一个包含所需代码的模块,但要延迟到首次调用所需的函数。

我们需要使用 JSPI,因为 API 之类fetch的本质上是异步的,但我们希望能够从应用程序中的任意位置调用它们,特别是从调用尚不存在的函数的中间调用它们。

核心思想是用存根替换一个动态加载的函数;这个存根首先加载缺失的函数代码,用加载的代码替换自己,然后用原始参数调用新加载的代码。对该函数的任何后续调用都会直接转到加载的函数。此策略允许采用本质上透明的方法来动态加载代码。

我们要加载的模块非常简单,它包含一个返回的函数42:

// This is a simple provider of forty-two
#include 

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我们添加到 build of的标志p42.c是为了确保它可以访问与主模块相同的内存。[5]

为了动态加载代码,我们使用标准WebAssembly.instantiateStreamingAPI:

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

该表达式用于fetch定位已编译的 Wasm 模块,WebAssembly.instantiateStreaming编译获取的结果并从中创建一个实例化模块。两者都fetch返回WebAssembly.instantiateStreaming承诺;所以我们不能简单地访问结果并提取我们需要的功能。相反,我们使用宏将其包装到 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是 Emscripten API 的一部分,但为了确保它在运行时对我们可用,我们必须告知emcc它是必需的依赖项。我们在以下行中执行此操作:

EM_JS_DEPS(funDeps, "$addFunction")

在我们想要动态加载代码的情况下,我们希望确保不会加载不必要的代码;在这种情况下,我们希望确保后续调用provide42不会触发重新加载。C 有一个简单的特性,我们可以为此使用:我们不provide42直接调用,而是通过 trampoline 调用,这将导致加载函数,然后,就在实际调用函数之前,将 trampoline 更改为绕过自身. 我们可以使用适当的函数指针来做到这一点:

extern fooFun get42;

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

fooFun get42 = stub;

从程序其余部分的角度来看,我们要调用的函数称为get42。它的初始实现是 via stub,它调用resolveFun实际加载函数。加载成功后,我们将 get42 更改为指向新加载的函数——并调用它。

我们的主函数调用get42了两次:[6]

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

在浏览器中运行它的结果是一个看起来像这样的日志

loading promise42
first call p42() = 42
second call = 42

请注意,该行loading promise42只出现一次,而get42实际上被调用了两次。

这个例子演示了 JSPI 可以以一些意想不到的方式使用:动态加载代码似乎离创建承诺还有很长的路要走。此外,还有其他方法可以将 WebAssembly 模块动态链接在一起;这并不代表该问题的最终解决方案。

我们非常期待看到您可以使用这项新功能做什么!

附录 A:完整清单badfib

#include 
#include 
#include 
#include 

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((k, r) => {
    setTimeout(() => {
      return k(x + y);
    }, 0);
  });
});

__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
",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.cC 代码代表我们动态加载示例的主要部分:

#include 
#include 

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
", get42());
  printf("second call = %ld
", get42());
}

p42.c代码是动态加载的模块。

#include 

EMSCRIPTEN_KEEPALIVE long provide42() {
  return 42l;
}

笔记


  1. 对于技术上的好奇,请参阅WebAssembly proposal for JSPI and the V8 stack switching design portfolio。
  2. 注意:我们在附录 A 中包含了下面的完整程序。
  3. 注意:您需要 Emscripten 3.1.28 的版本。
  4. 该ASYNCIFY=2选项是对访问异步 API 的一种方式的引用——使用 Emscripten 的异步化功能。
  5. 对于我们的特定示例,我们不需要此标志,但您可能需要它来处理更大的示例。
  6. 完整程序见附录B。

由 Francis McCabe、Thibaud Michaud、Ilya Rezvov、Brendan Dahl 发表。

展开阅读全文

页面更新:2024-05-13

标签:示例   应用程序   函数   模块   加载   代码   操作   功能   时间   动态

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2008-2024 All Rights Reserved. Powered By bs178.com 闽ICP备11008920号-3
闽公网安备35020302034844号

Top