异步代码

Emscripten 支持两种方式(Asyncify 和 JSPI),允许 **同步** C 或 C++ 代码与 **异步** JavaScript 交互。这允许以下操作:

  • C 中的同步调用会让出控制权给事件循环,允许处理浏览器事件。

  • C 中的同步调用会等待 JS 中的异步操作完成。

一般来说,这两种选择非常相似,但它们依赖于不同的底层机制来工作。

  • Asyncify - Asyncify 自动将你编译的代码转换为可暂停和恢复的形式,并为你处理暂停和恢复,使其即使是用普通同步方式编写,也能实现异步(因此得名“Asyncify”)。这在大多数环境中都有效,但会导致 Wasm 输出的大小显著增加。

  • JSPI(实验性) - 使用 VM 对 JavaScript Promise 集成 (JSPI) 的支持来与异步 JavaScript 交互。代码大小将保持不变,但对该功能的支持仍在实验阶段。

有关 Asyncify 的更多信息,请参阅 Asyncify 简介博客文章,了解一般背景以及其内部工作原理的详细信息(你也可以观看 关于 Asyncify 的演讲)。以下内容扩展了该文章中的 Emscripten 示例。

休眠/让出控制权给事件循环

让我们从该博客文章中的示例开始

// example.cpp
#include <emscripten.h>
#include <stdio.h>

// start_timer(): call JS to set an async timer for 500ms
EM_JS(void, start_timer, (), {
  Module.timer = false;
  setTimeout(function() {
    Module.timer = true;
  }, 500);
});

// check_timer(): check if that timer occurred
EM_JS(bool, check_timer, (), {
  return Module.timer;
});

int main() {
  start_timer();
  // Continuously loop while synchronously polling for the timer.
  while (1) {
    if (check_timer()) {
      printf("timer happened!\n");
      return 0;
    }
    printf("sleeping...\n");
    emscripten_sleep(100);
  }
}

你可以使用 -sASYNCIFY-sJSPI 编译它

emcc -O3 example.cpp -s<ASYNCIFY or JSPI>

注意

使用 Asyncify 时,进行优化 (-O3) 非常重要,因为未优化的版本非常大。

你可以使用以下命令运行它:

nodejs a.out.js

或者使用 JSPI

nodejs --experimental-wasm-stack-switching a.out.js

然后你应该看到类似以下内容:

sleeping...
sleeping...
sleeping...
sleeping...
sleeping...
timer happened!

代码是用一个简单的循环编写的,它在运行时不会退出,通常情况下,这将不允许浏览器处理异步事件。有了 Asyncify/JSPI,这些休眠实际上会让出控制权给浏览器的主要事件循环,定时器就可以工作了!

让异步 Web API 仿佛是同步的

除了 emscripten_sleep 和其他标准同步 API 之外,Asyncify 还支持添加你自己的函数。为此,你需要创建一个从 Wasm 调用的 JS 函数(因为 Emscripten 从 JS 运行时控制着 Wasm 的暂停和恢复)。

一种方法是使用 JS 库函数。另一种方法是使用 EM_ASYNC_JS,我们将在下一个示例中使用它

// example.c
#include <emscripten.h>
#include <stdio.h>

EM_ASYNC_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");
}

在这个示例中,异步操作是 fetch,这意味着我们需要等待 Promise。虽然该操作是异步的,但请注意,main() 中的 C 代码是完全同步的!

要运行此示例,首先用以下命令编译它:

emcc example.c -O3 -o a.html -s<ASYNCIFY or JSPI>

要运行它,你需要运行一个 本地 Web 服务器,然后浏览到 https://127.0.0.1:8000/a.html。你将看到类似以下内容:

before
waiting for a fetch
got the fetch response
after

这表明 C 代码只在异步 JS 完成后才继续执行。

在旧版引擎中使用 Asyncify API 的方法

如果你的目标 JS 引擎不支持现代的 async/await JS 语法,你可以使用 EM_JSAsyncify.handleAsync 将上面的 do_fetch 实现转换为使用 Promise 的形式

EM_JS(int, do_fetch, (), {
  return Asyncify.handleAsync(function () {
    out("waiting for a fetch");
    return fetch("a.html").then(function (response) {
      out("got the fetch response");
      // (normally you would do something with the fetch here)
      return 42;
    });
  });
});

使用此形式时,编译器不再静态地知道 do_fetch 是异步的。相反,你需要告诉编译器 do_fetch() 可以执行异步操作,方法是使用 ASYNCIFY_IMPORTS,否则它不会对代码进行检测以允许暂停和恢复(有关更多详细信息,请参阅后面的内容)

emcc example.c -O3 -o a.html -sASYNCIFY -sASYNCIFY_IMPORTS=do_fetch

最后,如果你也不能使用 Promise,你可以将示例转换为使用 Asyncify.handleSleep,它会将 wakeUp 回调传递给你函数的实现。当这个 wakeUp 回调被调用时,C/C++ 代码将恢复

EM_JS(int, do_fetch, (), {
  return Asyncify.handleSleep((wakeUp) => {
    out("waiting for a fetch");
    fetch("a.html").then(function (response) {
      out("got the fetch response");
      // (normally you would do something with the fetch here)
      wakeUp(42);
    });
  });
});

请注意,使用此形式时,你无法从函数本身返回一个值。相反,你需要将它作为参数传递给 wakeUp 回调,并通过在 do_fetch 本身中返回 Asyncify.handleSleep 的结果来进行传播。

关于 ASYNCIFY_IMPORTS 的更多信息

与上面的示例一样,你可以添加执行异步操作但从 C 的角度来看是同步的 JS 函数。如果你没有使用 EM_ASYNC_JS,那么将这些方法添加到 ASYNCIFY_IMPORTS 中至关重要。该导入列表是 Wasm 模块的导入列表,Asyncify 检测必须了解该列表。将该列表提供给它可以告诉它所有其他 JS 调用 **不会** 执行异步操作,这使得它可以在不需要的地方不会添加开销。

注意

如果导入不在 env 内,则必须指定完整路径,例如,ASYNCIFY_IMPORTS=wasi_snapshot_preview1.fd_write

带动态链接的 Asyncify

如果你想在动态库中使用 Asyncify,那么从其他链接的模块中导入的那些方法(这些方法将在异步操作中位于堆栈中)应该列在 ASYNCIFY_IMPORTS 中。

// sleep.cpp
#include <emscripten.h>

extern "C" void sleep_for_seconds() {
  emscripten_sleep(100);
}

在侧边模块中,你可以按照常规 Emscripten 动态链接方式编译 sleep.cpp

emcc sleep.cpp -O3 -o libsleep.wasm -sASYNCIFY -sSIDE_MODULE
// main.cpp
#include <emscripten.h>

extern "C" void sleep_for_seconds();

int main() {
  sleep_for_seconds();
  return 0;
}

在主模块中,编译器并不知道 sleep_for_seconds 是异步的。因此,你需要将 sleep_for_seconds 添加到 ASYNCIFY_IMPORTS 列表中。

emcc main.cpp libsleep.wasm -O3 -sASYNCIFY -sASYNCIFY_IMPORTS=sleep_for_seconds -sMAIN_MODULE

与 Embind 一起使用

如果你正在使用 Embind 与 JavaScript 进行交互,并且想要 await 动态检索到的 Promise,你可以直接在 val 实例上调用 await() 方法

val my_object = /* ... */;
val result = my_object.call<val>("someAsyncMethod").await();

在这种情况下,你不需要担心 ASYNCIFY_IMPORTSJSPI_IMPORTS,因为这是 val::await 的内部实现细节,Emscripten 会自动处理它。

请注意,使用 Embind 导出时,Asyncify 和 JSPI 的行为不同。当使用 Asyncify 与 Embind 一起使用并且代码从 JavaScript 调用时,如果导出调用了任何挂起函数,则该函数将返回一个 Promise,否则将同步返回结果。但是,对于 JSPI,必须使用参数 emscripten::async() 来将该函数标记为异步,并且导出将始终返回一个 Promise,无论导出是否挂起。

#include <emscripten/bind.h>
#include <emscripten.h>

static int delayAndReturn(bool sleep) {
  if (sleep) {
    emscripten_sleep(0);
  }
  return 42;
}

EMSCRIPTEN_BINDINGS(example) {
  // Asyncify
  emscripten::function("delayAndReturn", &delayAndReturn);
  // JSPI
  emscripten::function("delayAndReturn", &delayAndReturn, emscripten::async());
}

使用以下命令构建:

emcc -O3 example.cpp -lembind -s<ASYNCIFY or JSPI>

然后从 JavaScript 调用它(使用 Asyncify)

let syncResult = Module.delayAndReturn(false);
console.log(syncResult); // 42
console.log(await syncResult); // also 42 because `await` is no-op

let asyncResult = Module.delayAndReturn(true);
console.log(asyncResult); // Promise { <pending> }
console.log(await asyncResult); // 42

与总是返回 Promise 的 JavaScript async 函数形成对比,返回值是在运行时确定的,并且只有在遇到 Asyncify 调用(例如 emscripten_sleep()val::await() 等)时才会返回 Promise

如果代码路径不确定,调用者可以检查返回的值是否是 instanceof Promise,或者简单地对返回值进行 await

使用 JSPI 时,返回的值将始终是 Promise,如下所示

let syncResult = Module.delayAndReturn(false);
console.log(syncResult); // Promise { <pending> }
console.log(await syncResult); // 42

let asyncResult = Module.delayAndReturn(true);
console.log(asyncResult); // Promise { <pending> }
console.log(await asyncResult); // 42

ccall 一起使用

要从 Javascript 使用使用 Asyncify 的 Wasm 导出,你可以使用 Module.ccall 函数,并将 async: true 传递给它的调用选项对象。然后 ccall 将返回一个 Promise,该 Promise 将在计算完成后使用函数的结果进行解析。

在这个例子中,调用了一个名为“func”的函数,该函数返回一个数字。

Module.ccall("func", "number", [], [], {async: true}).then(result => {
  console.log("js_func: " + result);
});

Asyncify 和 JSPI 的区别

除了使用不同的底层机制,Asyncify 和 JSPI 还以不同的方式处理异步导入和导出。Asyncify 会自动确定哪些导出将成为异步的,这取决于什么可能调用异步导入 (ASYNCIFY_IMPORTS)。然而,对于 JSPI,必须使用 JSPI_IMPORTSJSPI_EXPORTS 设置显式设置异步导入和导出。

注意

<JSPI/ASYNCIFY>_IMPORTSJSPI_EXPORTS 在使用上面提到的各种辅助函数时不需要,例如:EM_ASYNC_JS,Embind 的异步支持,ccall 等等…

优化 Asyncify

注意

本节不适用于 JSPI。

如前所述,使用 Asyncify 的非优化构建可能很大且速度很慢。使用优化(例如,-O3)进行构建以获得良好的结果。

Asyncify 会增加开销,包括代码大小和速度变慢,因为它会对代码进行插桩以允许展开和回溯。这种开销通常并不极端,大约为 50% 左右。Asyncify 通过进行全程序分析来实现这一点,以查找需要进行插桩的函数以及哪些函数不需要进行插桩——基本上,哪些函数可以调用到达 ASYNCIFY_IMPORTS 之一的函数。这种分析避免了许多不必要的开销,但是,它受到**间接调用**的限制,因为它无法确定它们将去哪里——它可能是函数表中任何东西(具有相同的类型)。

如果您知道展开时间接调用永远不会在堆栈上,那么您可以告诉 Asyncify 使用 ASYNCIFY_IGNORE_INDIRECT 忽略间接调用。

如果您知道一些间接调用很重要,而另一些则不重要,那么您可以提供一个手动函数列表给 Asyncify

  • ASYNCIFY_REMOVE 是一个不展开堆栈的函数列表。当 Asyncify 处理调用树时,此列表中的函数将被删除,并且它们以及它们的调用者都不会被插桩(除非它们的调用者由于其他原因需要被插桩)。

  • ASYNCIFY_ADD 是一个确实会展开堆栈的函数列表,并将像导入一样进行处理。这在您使用 ASYNCIFY_IGNORE_INDIRECT 但还想标记一些需要展开的附加函数时特别有用。但是,如果 ASYNCIFY_PROPAGATE_ADD 设置被禁用,那么此列表将仅在全程序分析之后添加。如果 ASYNCIFY_PROPAGATE_ADD 被禁用,那么您还必须添加它们的调用者、它们的调用者的调用者等等。

  • ASYNCIFY_ONLY 是一个**唯一**可以展开堆栈的函数列表。Asyncify 将对这些函数进行插桩,而不会对其他函数进行插桩。

您可以启用 ASYNCIFY_ADVISE 设置,它将告诉编译器输出它当前正在插桩哪些函数以及原因。然后,您可以确定是否应该将任何函数添加到 ASYNCIFY_REMOVE 或是否可以安全地启用 ASYNCIFY_IGNORE_INDIRECT。请注意,编译器的此阶段发生在许多优化阶段之后,并且可能已经内联了多个函数。为了安全起见,请使用 -O0 运行它。

有关更多详细信息,请参阅 settings.js。请注意,此处提到的手动设置很容易出错——如果您没有正确设置,您的应用程序可能会崩溃。如果您不需要绝对的最大性能,通常可以使用默认设置。

潜在问题

堆栈溢出(Asyncify)

如果您看到从 asyncify_* API 抛出的异常,那么它可能是堆栈溢出。您可以使用 ASYNCIFY_STACK_SIZE 选项增加堆栈大小。

重入

在等待异步操作时,可能会发生浏览器事件。这通常是使用 Asyncify 的目的,但也会发生意外事件。例如,如果您只是想暂停 100 毫秒,那么您可以调用 emscripten_sleep(100),但是如果您有任何事件监听器,例如按键监听器,那么如果按下了某个键,处理程序就会触发。如果该处理程序调用编译后的代码,那么它可能会令人困惑,因为它开始看起来像协程或多线程,多个执行交织在一起。

在另一个异步操作正在运行时安全地启动异步操作。第一个必须完成才能开始第二个。

这种交织也可能会破坏您代码库中的假设。例如,如果某个函数使用全局变量并假设在它返回之前没有任何其他内容可以修改它,但是如果该函数睡眠并且某个事件导致其他代码更改该全局变量,那么可能会发生不好的事情。

在堆栈上带有编译代码的情况下开始回溯(Asyncify)

上面的示例显示了从 JS(通常在回调之后)调用 wakeUp(),并且堆栈上没有任何编译代码。如果堆栈上存在编译代码,那么这可能会以令人困惑的方式干扰正确回溯和恢复执行,因此在使用 ASSERTIONS 进行构建时会抛出断言。

(具体来说,那里存在的问题是,虽然回溯将正常工作,但是如果您稍后再次展开,该展开也将通过堆栈上存在的额外编译代码进行展开——导致稍后的回溯行为异常。)

您可能会发现一个简单的解决方法是用 0 的超时替换 wakeUp(),用 setTimeout(wakeUp, 0);。这将在稍后的回调中运行 wakeUp,此时堆栈上没有其他内容。

从旧的 Asyncify API 迁移

如果您有使用旧的 Emterpreter-Async API 或旧的 Asyncify 的代码,那么当您用 -sASYNCIFY 替换 -sEMTERPRETIFY 的使用时,几乎所有东西都应该正常工作。特别是所有类似 emscripten_wget 的东西应该像以前一样工作。

一些细微的差异包括

  • Emterpreter 有“yield”的概念,但它在 Asyncify 中并不需要。您可以用 emscripten_sleep() 调用替换 emscripten_sleep_with_yield() 调用。

  • 内部 JS API 不同。请参阅上面关于 Asyncify.handleSleep() 的说明,并参阅 src/library_async.js 以获取更多示例。