WebAssembly C++ 阻塞调用JS异步函数
一个wasm应用中,C/C++ 与JavaScript的交互必不可少,下面这种场景也不少见
//example.c
#include <emscripten.h>
#include <stdio.h>
EM_JS(int, do_fetch, (), {
out("waiting for a fetch");
const response = await fetch("a.html");
out("got the fetch response");
// (normally you would do something with the fetch here)
return 42;
int main() {
puts("before");
do_fetch();
puts("after");
在C++源码中,嵌入调用了一个js函数,其中包含了异步操作,那么函数实际执行的顺序肯定不会如我们所愿串行,C中的代码却是同步的,这导致结果可能是这样的:
before
waiting for a fetch
after
got the fetch response
这会给我们造成诸多麻烦,毕竟这种跨语言的调用的诡异行为违反了我们在C++种的编码习惯,或者说直觉。
针对这种情况,有没有优雅的异步转同步的方式呢?
那肯定是有的,那就是 ASYNCIFY
将EM_JS替换为EM_ASYNC_JS, 在编译参数中加入 - s ASYNCIFY
emcc example.c -O3 -o a.html -s ASYNCIFY
这下我们运行的结果就符合预期了,程序按序输出
before
waiting for a fetch
got the fetch response
after
那么EM_ASYNC_JS做了什么?来看一下源码:
#define _EM_JS(ret, c_name, js_name, params, code) \
_EM_JS_CPP_BEGIN \
ret c_name params EM_IMPORT(js_name); \
EMSCRIPTEN_KEEPALIVE \
__attribute__((section("em_js"), aligned(1))) char __em_js__##js_name[] = \
#params "<::>" code; \
_EM_JS_CPP_END
#define EM_JS(ret, name, params, ...) _EM_JS(ret, name, name, params, #__VA_ARGS__)
#define EM_ASYNC_JS(ret, name, params, ...) _EM_JS(ret, name, __asyncjs__##name, params, \
"{ return Asyncify.handleAsync(async () => " #__VA_ARGS__ "); }")
可见此宏做的事情就是用Asyncify.handleAsync封装了一次函数调用,由此进入ASYNCIFY的能力模块,
//src/library_async.js
// Unlike `handleSleep`, accepts a function returning a `Promise`
// and uses the fulfilled value instead of passing in a separate callback.
// This is particularly useful for native JS `async` functions where the
// returned value will "just work" and be passed back to C++.
handleAsync: function(startAsync) {
return Asyncify.handleSleep(function(wakeUp) {
// TODO: add error handling as a second param when handleSleep implements it.
startAsync().then(wakeUp);
函数再被封装了一层,进入Asyncify.handleSleep,这里真正执行异步转同步的操作,其基本原理是保存入口的堆栈,等待异步事件在下一次事件循环中被执行后,再恢复堆栈,返回到C++中。这样就实现了阻塞调用的效果。
需要了解的是,js是单线程的执行模型,这意味着所有的函数都是在一个线程中的事件循环里执行的。在C++ 到JS的交互中,C++中是同步执行,可以认为不存在事件循环,而进入js代码之后,在同一条线程上,就会产生了JS语境的事件循环,否则JS代码的异步函数也无法执行。这种现实情况下,想通过在C++中阻塞等待是行不通的,单线程中一个函数阻塞了,那JS的异步操作将永远无法被执行下去。
Asyncify 的工作原理
我们在 Asyncify 之类的东西中需要的基本功能是展开和回滚调用堆栈,在每种情况下跳回到函数中间的正确位置,并在执行此操作时保留局部变量。保存和重新加载本地变量相当简单(尽管有效地执行它需要一些工作;Asyncify 依赖于 Binaryen 优化器来提供帮助)。更棘手的是回到控制流中间的正确位置。有很多可能的方法可以做到这一点;正如前面提到的,Emscripten 有一个旧的 Asyncify 选项和 Emterpreter 功能,新的 Asyncify 试图改进它们,所以在这里简要总结一下历史很有趣。
旧的异步
旧的 Asyncify 是在 LLVM 的“fastcomp”分支中实现的,它适用于 LLVM IR。它会在每次相关调用后添加对堆栈展开的检查,如果是,则立即返回。为了回滚调用堆栈,它创建了与原始函数类似的新函数,但包含我们想要恢复的代码,并且它会执行间接调用来执行正确的函数。(这种代码重复与节点分裂修复不可约控制流的方式类似——通过克隆代码,我们可以避免控制流分支进入嵌套循环,这在 WebAssembly 或 asm.js 中是不可约且无法表示的。)
这里的一个缺点是此类额外函数的数量可能很大,这取决于有多少潜在的异步调用。增加代码大小的另一个来源是本地状态:旧的 Asyncify 在 LLVM IR 上运行,因此它在 LLVM 的 SSA 寄存器上运行。在最终的 WebAssembly 中,这样的寄存器通常比本地寄存器多得多。在实践中,我们有时会看到巨大的代码大小增加,这限制了旧的 Asyncify 的有用性。
请注意,旧的 Asyncify 与 LLVM 协程 无关(LLVM 在 2016 年添加了它们;Asyncify 是从 2014 年开始的)。LLVM 协程通过在 IR 级别不做完全降低来避免上述本地状态问题,同时意味着每个后端都必须添加支持,而 LLVM WebAssembly 后端还没有。在那里实现协程可能是一种不错的思路;一种选择可能是使用新的 Asyncify。
解释器
Emterpreter 是一个在 asm.js 中实现的小型解释型 VM,它运行自定义字节码(asm.js 被编译到其中)。作为虚拟机,它可以轻松地暂停和恢复执行,因为本地变量已经在堆栈上,并且有一个实际的程序计数器!这也保证了不会增加代码大小,因为字节码比 asm.js 或 WebAssembly 小,而且 VM 本身在任何小程序中都可以忽略不计。
当然,明显的问题是,作为解释器,这很慢。对此的主要解决方案是 选择性解释(这点很重要!) :通过告诉 Emterpreter 哪些代码要转换,哪些不理会,您可以全速保留重要的代码。如果每当您展开/回退时,堆栈上只有解释过的代码(因为我们无法展开/回退任何其他内容),则此方法有效。换句话说,如果你有这样的东西(在类似 JS 的伪代码中):
function caller() {
let x = foo();
sleep(100);
return x;
然后如果
foo
无法展开堆栈,则可以全速运行它。您只需要 emterpret
caller
,如果无论如何都不需要花费大量时间,这可能没问题。
首先上述的指定符号展开能力,需要在编译时设置 ASYNCIFY _IMPORTS参数
-s 'ASYNCIFY_IMPORTS=["caller"]'
换言之,只有在这个导出列表内的函数,才能使用到ASYNCIFY的特性。
新的异步
如前所述,新的 Asyncify(我们在本文的其余部分简称为“Asyncify”)试图改进那些早期的方法。第一个设计决定是它在 WebAssembly 上运行,因此它在 Binaryen 中实现。这避免了我们之前提到的许多 SSA 寄存器的问题,并且 Asyncify 与 Binaryen 优化器集成以尽可能减少本地的数量。
那么最大的问题是如何处理控制流。Asyncify 对最后一个代码片段执行类似的操作(同样,在类似 JS 的伪代码中):
function caller() {
let x;
if (rewinding) { .. restore x .. } //可以看成一次函数调用,将本地变量压栈保存
if (normal) {
x = foo();
if (normal || .. check call index ..) {
sleep(100); // 保存现场,跳过此次调用,进入下一次事件循环
if (unwinding) { //展开堆栈,恢复程序流
.. save call index and x ..
return;
return x; //这样在上层看来,此次调用确实是同步的阻塞调用
这乍一看可能令人困惑,但实际上非常简单。标识符“normal”、“rewinding”、“unwinding”意味着我们检查我们是否在正常运行、回绕调用堆栈或展开它。如果我们正在倒带,我们首先恢复本地状态。我们还通过检查我们是否正常运行来保护大多数代码 - 如果我们正在展开堆栈,我们必须跳过该代码,因为我们已经执行了它。当我们到达一个 可能展开堆栈的调用 时,我们有一个“调用索引”,以便我们知道在每个函数内部返回哪个调用。如果调用开始展开,我们保存调用索引和本地状态并立即退出。或者,如果我们正在展开堆栈,那么调用会将状态设置为正常,然后我们将继续从正确的位置继续执行。
这里的关键原则是我们不做任何复杂的 CFG 转换。相反,Asyncify 的方法是在回滚时 跳过代码 ,始终向前移动,以便我们最终到达正确的位置(该算法在 Binaryen 内部称为“Bysyncify”)。重要的是,我们可以将这些 if 放在无法展开的整块代码中,就像一个没有调用的循环:
if (normal) { //也就是说,asyncify并不是完全按照函数压栈的逻辑,在没有函数调用的流程中,
for (let i = 0; i < 1000; i++) { //直接执行指令,不考虑这部分本地变量的展开
total += i;
整个循环都在 if 里面,这意味着它全速运行!这是 Asyncify 比您预期的更快的重要原因。
Asyncify 速度快的另一个原因是我们分析了整个程序的调用图,看看哪些函数可能会展开,这样我们就可以避免修改不能展开的函数。这就是为什么我们在调用
foo
之前的示例后没有检查展开。
此外,如果回绕则跳过代码并在展开时返回的 ifs 不是那么糟糕,如果展开/回绕是相当罕见的事件,因为它们将是现代 CPU 处理良好的预测良好的分支。它们也不干扰现有的控制流结构,例如,这个循环:
while (1) {
total += i;
i++;
maybeSleep();
可能会像这样结束:
while (1) {
if (normal) {
total += i;
i++;
if (normal || .. check call index ..) {
maybeSleep();
if (unwinding) {