- 编译器
- WebAssembly
- 开发工具
- 构建工具
【免费下载链接】emscripten
Emscripten: An LLVM-to-WebAssembly Compiler
Emscripten 提供 Asyncify 与 JSPI(JavaScript Promise Integration)两种机制,让以同步方式编写的 C/C++ 代码能够与异步 JavaScript 交互:既可以让同步调用主动让出主事件循环,也可以让同步调用等待某个异步 JS 操作(如fetch)完成。本文以 Emscripten 官方文档为基础,结合仓库源码深入讲解两种机制的编译用法、EM_ASYNC_JS与__async标记、ASYNCIFY_IMPORTS配置、动态链接与 Embind 集成,以及性能优化与常见陷阱,帮助你写出既保持同步代码直觉、又具备异步能力的 WebAssembly 程序。
Asyncify 与 JSPI:两种异步化的技术路线
从概念上讲,Asyncify 和 JSPI 解决的是同一个问题:Wasm 本身是同步执行的,一旦调用栈进入 Wasm,除非执行完毕返回 JS,否则浏览器事件循环无法插队。两者都能把"同步形态"的 C/C++ 调用改造成可暂停(pause)与可恢复(resume)的形式,但底层机制完全不同:
- Asyncify:编译期自动对代码做二进制变换(binaryen 的 Asyncify pass),把程序改造成可以被"暂停—恢复"的形态,并在运行时替你处理暂停与恢复的全部细节。因此即使用同步方式写代码,编译产物也是异步的——这正是"Asyncify"名字的由来。它在绝大多数环境中都能工作,代价是生成的 Wasm 体积显著变大。
- JSPI:利用 VM 对 JavaScript Promise Integration 提案的原生支持(
WebAssembly.Suspending/WebAssembly.promising等 API)来实现与异步 JS 的交互,不改写 Wasm,因此代码体积保持不变。
在仓库中,这两种模式统一在运行时库 src/lib/libasync.js 中实现:ASYNCIFY == 1对应传统的 Asyncify 二进制变换模式,ASYNCIFY == 2(即-sJSPI)对应 JSPI 模式,两套分支共享instrumentWasmImports/instrumentWasmExports等入口逻辑。而对应编译开关的定义位于 src/settings.js,ASYNCIFY的默认值为 0,注释中明确指出 JSPI 是取代旧式 Asyncify 模式的推荐路线(-sASYNCIFY=2已废弃,改用-sJSPI)。
关于 Asyncify 内部工作原理的详细背景,可参阅 Asyncify 的原始介绍文章与其配套演讲(原文链接详见 site/source/docs/porting/asyncify.rst)。下文是对该文章中 Emscripten 例子的系统展开。
让出事件循环:基于emscripten_sleep的同步轮询
先看官方文档中最经典的例子:C 代码启动一个 JS 定时器,然后在一个不退出的同步循环里轮询定时器是否触发。
// 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),因为未优化构建的产物会非常大。
运行方式(Asyncify 与 JSPI 略有差异):
# Asyncify:普通 Node 即可 node a.out.js # JSPI:需要 Node 开启 wasm 栈切换实验特性 node --experimental-wasm-stack-switching a.out.js预期输出如下:
sleeping... sleeping... sleeping... sleeping... sleeping... timer happened!这个循环用普通方式编写、运行时从不退出,正常情况下浏览器根本无法处理异步事件;但有了 Asyncify/JSPI 之后,每次emscripten_sleep(100)都会真正让出浏览器的主事件循环,500ms 的定时器得以触发,循环随之结束。
从源码看,emscripten_sleep的实现位于 src/lib/libasync.js:
emscripten_sleep__async: 'auto', emscripten_sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),它本质上就是把"等待 N 毫秒"包装成一个 Promise,__async: 'auto'标记使其自动纳入 Asyncify 的暂停/恢复流程。对应的 C 声明位于 system/include/emscripten/emscripten.h(void emscripten_sleep(unsigned int ms);)。如果没有开启 Asyncify/JSPI,src/lib/libasync.js 中的兜底实现会直接abort,提示"请使用异步支持编译以使用emscripten_sleep之类的异步操作",避免静默出错。
让异步 Web API 表现得像同步调用:EM_ASYNC_JS
除了emscripten_sleep和内置的同步 API 之外,你还可以自定义"看起来同步、实际异步"的 JS 函数。实现方式是创建一个从 Wasm 调用的 JS 函数(因为暂停与恢复 Wasm 的控制权在 JS 运行时手中),途径有两种:JS library 函数,或EM_ASYNC_JS宏。下面用EM_ASYNC_JS演示如何在同步 C 代码中await一个fetch:
// 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"); }注意这里main()中的 C 代码完全是同步写法,但do_fetch内部等待了一个真正的 Promise。
编译方式:
emcc example.c -O3 -o a.html -s<ASYNCIFY or JSPI>由于涉及fetch,必须通过本地 Web 服务器 提供页面(文档原文指引为 local webserver),然后访问http://localhost:8000/a.html,输出如下:
before waiting for a fetch got the fetch response afterafter在 fetch 响应之后才打印,说明 C 代码确实是在异步 JS 完成后才继续执行的。
从宏定义看,system/include/emscripten/em_js.h 中EM_ASYNC_JS展开为_EM_JS并生成名为__asyncjs__do_fetch的函数——__asyncjs__前缀正是编译器识别"这是异步 JS 函数、需要自动接入 Asyncify 流程"的标志。仓库中该用法的典型测试可见 test/core/test_em_async_js.c。
用__async标记 JS library 函数为异步
如果你用的是 JS library 文件(--js-library),只需给函数加上__async修饰符,编译器就会替你处理 Asyncify API 的全部细节,并自动把该函数加入ASYNCIFY_IMPORTS。你只需编写普通的异步 JS 函数——既可以用显式的async关键字,也可以直接返回Promise对象。例如:
addToLibrary({ fetch_v1__async: 'auto', fetch_v1: async (url) => { const response = await fetch(UTF8ToString(url)); const json_data = await response.json(); return stringToNewUTF8(json_data); }, fetch_v2__async: 'auto', fetch_v2: (url) => { return fetch(UTF8ToString(url)) .then((rsp) => response.json()) .then((json_data) => stringToNewUTF8(json_data)); }, });fetch_v1与fetch_v2功能完全一致,只是分别用了async/await与 Promise 链式写法。它们都被标记为__async: 'auto',意味着调用时会自动挂起 Wasm 执行,待返回的 Promise resolve 后再恢复。
两种标记值的区别:
__async: 1:仅把该函数加入ASYNCIFY_IMPORTS(告知编译器它可能发起异步操作)。__async: 'auto':除加入ASYNCIFY_IMPORTS外,还会用Asyncify.handleAsync把函数包装起来,真正实现暂停与恢复。
仓库中__async: 'auto'的实际运用可以参考 src/lib/libasync.js:emscripten_sleep与emscripten_wget_data都以此标记实现;emscripten_scan_registers则用__async: true配合Asyncify.handleSleep手动管理暂停细节。
兼容旧引擎:Asyncify.handleAsync与Asyncify.handleSleep
如果目标 JS 引擎不支持现代async/await语法,可以把上面do_fetch的实现"降级"为基于 Promise 的写法——用EM_JS配合Asyncify.handleAsync:
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是异步的,因此必须通过ASYNCIFY_IMPORTS明确告知编译器do_fetch()可能执行异步操作,否则不会为代码插桩(instrument)以支持暂停与恢复:
emcc example.c -O3 -o a.html -sASYNCIFY -sASYNCIFY_IMPORTS=do_fetch如果连 Promise 都无法使用,还可以进一步降级为Asyncify.handleSleep——它会向你的函数实现传入一个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(...)的结果来向外传递。
从运行时实现看,这两个 API 都定义在 src/lib/libasync.js 的$Asyncify对象中。handleSleep(startAsync)的核心流程是:若当前状态为 Normal,则先调用startAsync,若回调是同步触发的则无需异步化;若确实发起了异步操作,则切换到 Unwinding 状态、分配 asyncify 数据结构(allocateData,其中内嵌了一块大小为ASYNCIFY_STACK_SIZE的栈)、调用_asyncify_start_unwind展开调用栈;当wakeUp被调用时切换到 Rewinding 状态,调用_asyncify_start_rewind并doRewind恢复执行。而handleAsync(startAsync)则是handleSleep的 Promise 友好封装(wakeUp(await startAsync()))。
深入ASYNCIFY_IMPORTS配置
如上面例子所示,你可以让某些"从 C 视角看是同步的"JS 函数实际执行异步操作。如果不用EM_ASYNC_JS或__async这类自动机制,就必须把这些方法加入ASYNCIFY_IMPORTS。这个列表就是 Asyncify 插桩需要感知的 Wasm 模块导入清单:告诉编译器"除此之外的其他 JS 调用都不会执行异步操作",从而避免在不必要的地方引入开销。
几点关键细节:
- 默认的异步导入(如
emscripten_sleep)由 Emscripten 自动添加,你无需、也不应该重复列出(历史版本曾要求手动列出默认项,现已在 src/settings.js 中改为自动加入)。 - 如果导入不在
env命名空间中,必须写完整路径,例如ASYNCIFY_IMPORTS=wasi_snapshot_preview1.fd_write。 - 在启用
ASSERTIONS的构建中,如果某个 import 改变了 Asyncify 状态却又不在ASYNCIFY_IMPORTS中,运行时会在 src/lib/libasync.js 的instrumentWasmImports包装层中直接abort,提示"import X was not in ASYNCIFY_IMPORTS, but changed the state",帮助你尽早发现漏配。
ASYNCIFY_IMPORTS的定义见 src/settings.js,默认值为[]。
Asyncify 与动态链接(Dynamic Linking)
要在动态库中使用 Asyncify,那些从其他链接模块导入、且会在异步操作期间位于调用栈上的方法,都应列入ASYNCIFY_IMPORTS。
侧模块(side module)侧:
// sleep.cpp #include <emscripten.h> extern "C" void sleep_for_seconds() { emscripten_sleep(100); }按标准的 Emscripten 动态链接方式编译侧模块:
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是异步的,因此必须手动将其加入ASYNCIFY_IMPORTS:
emcc main.cpp libsleep.wasm -O3 -sASYNCIFY -sASYNCIFY_IMPORTS=sleep_for_seconds -sMAIN_MODULE从实现看,动态链接场景中运行时对MAIN_MODULE有特殊处理:src/lib/libasync.js 在包装导入时会保留.sig签名属性供动态库加载器解析函数签名;instrumentFunction生成的 wrapper 也会记录orig指向原函数(src/lib/libasync.js)。仓库中test/core/test_dlfcn_jspi.c、test/core/test_pthread_join_and_asyncify.c等测试覆盖了相关联动场景。
与 Embind 集成:val::await()与导出行为差异
如果使用 Embind 与 JavaScript 交互,并想await一个动态获取的Promise,可以直接在val实例上调用await()方法:
val my_object = /* ... */; val result = my_object.call<val>("someAsyncMethod").await();此时无需关心ASYNCIFY_IMPORTS或JSPI_IMPORTS,因为这是val::await的内部实现细节,Emscripten 会自动处理。
需要特别注意:使用 Embind 导出时,Asyncify 与 JSPI 的行为不同。
- Asyncify:从 JS 调用导出函数时,如果导出内部调用了任何挂起函数(suspending function),则该函数返回一个
Promise;否则同步返回结果。返回值在运行时才确定——这与 JSasync函数"总是返回 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>使用 Asyncify 时,从 JavaScript 调用:
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只有当代码路径中实际遇到 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,在计算完成后 resolve 为函数结果。
例如调用一个名为 "func"、返回 Number 的函数:
Module.ccall("func", "number", [], [], {async: true}).then(result => { console.log("js_func: " + result); });Asyncify 与 JSPI 的差异对照
除了底层机制不同,两者在处理异步导入/导出时的方式也不同:
- Asyncify:自动根据"哪些导出可能调用异步导入(
ASYNCIFY_IMPORTS)"来确定哪些导出会变成异步。 - JSPI:异步导入和导出必须通过
JSPI_IMPORTS与JSPI_EXPORTS设置显式声明。其中JSPI_EXPORTS默认包含main,每个列出的导出都会返回一个以结果 resolve 的Promise(见 src/settings.js);JSPI_IMPORTS中列出的导入函数在执行异步工作时应返回Promise,也可以像 JS library 那样用<function_name>_async: true标记来代替(见 src/settings.js)。
注意:使用上述各类辅助机制时(
EM_ASYNC_JS、Embind 的 Async 支持、ccall等),通常不需要手动设置<JSPI/ASYNCIFY>_IMPORTS与JSPI_EXPORTS,这些机制会自动完成登记。
优化 Asyncify:开销来源与手动调优
本节只适用于 Asyncify,不适用于 JSPI(JSPI 不改写 Wasm,无此开销)。
如前所述,未优化的 Asyncify 构建会又大又慢,务必使用优化选项(如-O3)。Asyncify 因为对代码插桩以支持 unwind(展开)与 rewind(回卷),会带来体积和速度上的双重开销——通常约 50% 左右,并非极端夸张。它通过全程序分析找出哪些函数需要插桩、哪些不需要(即哪些函数可能调用到ASYNCIFY_IMPORTS中的某个异步导入),从而避免大量不必要的开销。但这个分析受限于间接调用:分析器无法预知间接调用的目标,它可能指向函数表中任意同类型的函数。
据此可以手动优化:
ASYNCIFY_IGNORE_INDIRECT:如果你能确定 unwind 时调用栈上不会出现间接调用,可以告诉 Asyncify 忽略间接调用,收益极大(否则 Asyncify 必须假设一次间接调用可能到达几乎任何地方)。定义见 src/settings.js。ASYNCIFY_REMOVE:列出"不会 unwind 栈"的函数列表。Asyncify 处理调用树时,列表中的函数会被移除,它们及其调用者都不会被插桩(除非调用者因其他原因需要插桩)。常用于:你知道某些间接调用是安全的、不会 unwind 时。ASYNCIFY_ADD:列出"会 unwind 栈"的函数,按与 imports 相同的方式处理。主要用于你使用了ASYNCIFY_IGNORE_INDIRECT,但还想额外标记一些需要 unwind 的函数。若禁用ASYNCIFY_PROPAGATE_ADD,该列表只会在全程序分析之后追加,且必须手动把它们的调用者、调用者的调用者……一并加入。ASYNCIFY_PROPAGATE_ADD:默认true,开启时插桩状态会从 add-list 传播(其调用者、再上层调用者……);关闭后所有调用者都必须手动加入 add-list(类似 only-list 的用法)。见 src/settings.js。ASYNCIFY_ONLY:列出仅有的允许 unwind 栈的函数,Asyncify 只插桩这些函数、绝不插桩其他函数。与 remove-list 一样,搞错就会破坏应用。ASYNCIFY_ADVISE:开启后编译器会输出当前正在插桩哪些函数以及原因,据此判断是否应向ASYNCIFY_REMOVE添加函数,或能否安全启用ASYNCIFY_IGNORE_INDIRECT。注意这一编译阶段发生在许多优化阶段之后,部分函数可能已被内联,因此建议用-O0运行以获取准确建议。见 src/settings.js。
关于函数名的书写规则(对ASYNCIFY_REMOVE/ASYNCIFY_ADD/ASYNCIFY_ONLY均适用,详见 src/settings.js 注释):
- 使用 WebAssembly Names 段中的人类可读名称:C++ 写
Struct::func()而非_ZN6Struct4FuncEv;C 函数名不带参数(C++ 因重载需要带参数)。 - 支持简单的
*通配符匹配。 - 为避免操作系统 shell 与构建系统的转义问题,支持替换:(空格)→
.、&→#、,→?。例如"foo(char const*, int&)"可写作"foo(char.const*?.int#)"。注意空白是函数签名的一部分,"foo(char const *, int &)"不会匹配"foo(char const*, int&)"。
另外还有两个相关开关:src/settings.js 中ASYNCIFY_STACK_SIZE(默认 4096,用于存储 unwind/rewind 信息的栈大小,过小会触发unreachable陷阱)和 src/settings.js 中ASYNCIFY_DEBUG(运行时调试日志,1 为最小、2 为详细)。
最后提醒:这些手动设置容易出错——只要有一处不精确,应用就可能崩溃。除非你确实需要压榨极致性能,否则通常使用默认值即可。
潜在问题与规避方法
栈溢出(Asyncify)
如果看到asyncify_*API 抛出异常,很可能是栈溢出。此时可以通过ASYNCIFY_STACK_SIZE增大异步栈大小。
重入(Reentrancy)
等待异步操作期间,浏览器事件可能发生——这常常正是使用 Asyncify 的目的,但意外的事件也可能发生。例如你只想暂停 100ms 而调用emscripten_sleep(100),但若注册了按键等事件监听器,按键时处理器会触发;若该处理器又调用编译代码,就会产生协程或多线程般的错觉——多个执行交错在一起。
在另一个异步操作进行期间启动新的异步操作是不安全的,第一个必须完成,第二个才能开始。
这种交错还可能破坏代码库中的既有假设:例如某个函数使用全局变量并假定在它返回前不会有别人修改它,但若该函数 sleep 期间有事件触发其他代码修改了这个全局变量,就会出问题。
在栈上存在编译代码时开始 rewind(Asyncify)
前面的例子都是wakeUp()在 JS 回调中被调用、调用栈上没有编译代码。如果调用wakeUp()时栈上有编译代码,会以令人困惑的方式干扰 rewind 与恢复执行(具体而言:rewind 本身能正常工作,但之后若再次 unwind,这次 unwind 也会穿过栈上那段多余的编译代码,导致后续 rewind 行为异常)。因此,在启用ASSERTIONS的构建中会直接抛出断言——见 src/lib/libasync.js 中handleSleep的断言:'waking up (starting to rewind) must be done from JS, without compiled code on the stack'。
一个简单实用的规避方法是用setTimeout(wakeUp, 0)替换直接的wakeUp()调用——让wakeUp在稍后的回调中运行,此时栈上已无其他代码。
从旧版 API 迁移
如果你的代码还在使用旧的 Emterpreter-Async API 或旧版 Asyncify,把-sEMTERPRETIFY替换为-sASYNCIFY后几乎一切都能直接工作,像emscripten_wget这类函数的行为与之前完全一致。仅有少量差异:
- Emterpreter 有"yielding"概念,Asyncify 中不再需要,可把
emscripten_sleep_with_yield()调用替换为emscripten_sleep()。 - 内部 JS API 不同:参考上文关于
Asyncify.handleSleep()的说明,更多示例可见运行时实现 src/lib/libasync.js。
推荐阅读路径
- 运行时实现:src/lib/libasync.js(
$Asyncify对象、emscripten_sleep/emscripten_wget_data/emscripten_scan_registers/ fiber 支持) - 编译开关与参数定义:src/settings.js(
ASYNCIFY、ASYNCIFY_IMPORTS、ASYNCIFY_IGNORE_INDIRECT、ASYNCIFY_STACK_SIZE、ASYNCIFY_REMOVE/ADD/ONLY/ADVISE、JSPI、JSPI_IMPORTS/JSPI_EXPORTS) - 头文件声明:system/include/emscripten/emscripten.h(
emscripten_sleep)、system/include/emscripten/em_js.h(EM_ASYNC_JS宏) - 测试用例:test/core/test_em_async_js.c、test/core/test_dlfcn_jspi.c、test/core/test_pthread_join_and_asyncify.c
- 其他相关设置可查阅设置参考文档
- 编译器
- WebAssembly
- 开发工具
- 构建工具
【免费下载链接】emscripten
Emscripten: An LLVM-to-WebAssembly Compiler
相关推荐
终极Flume异步编程指南:如何实现同步与异步代码无缝切换
终极Flume异步编程指南:如何实现同步与异步代码无缝切换 Flume是一个安全高效的多生产者多消费者通道库,专为Rust异步编程设计。本文将带你快速掌握Flu
Emscripten proxying.h 完全指南:跨线程任务代理与同步/异步调度机制
Emscripten proxying.h 完全指南:跨线程任务代理与同步/异步调度机制 本篇技术指南以 Emscripten 官方 API 参考文档 site
编译器WebAssembly开发工具构建工具Litestar 同步与异步编程模式完全指南:阻塞、线程池与 sync_to_thread 实战
Litestar 同步与异步编程模式完全指南:阻塞、线程池与 sync_to_thread 实战 Litestar 在几乎所有可行位置同时支持同步与异步可调用对
后端Web框架
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考