Emscripten 异步编程完全指南:Asyncify 与 JSPI 的同步代码异步化实战 编译器WebAssembly开发工具构建工具【免费下载链接】emscriptenEmscripten: An LLVM-to-WebAssembly Compiler项目地址https://gitcode.com/gh_mirrors/em/emscripten点击查看免费下载Emscripten 提供 Asyncify 与 JSPIJavaScript 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.jsASYNCIFY的默认值为 0注释中明确指出 JSPI 是取代旧式 Asyncify 模式的推荐路线-sASYNCIFY2已废弃改用-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 -sASYNCIFY 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.jsemscripten_sleep__async: auto, emscripten_sleep: (ms) new Promise((resolve) setTimeout(resolve, ms)),它本质上就是把等待 N 毫秒包装成一个 Promise__async: auto标记使其自动纳入 Asyncify 的暂停/恢复流程。对应的 C 声明位于 system/include/emscripten/emscripten.hvoid emscripten_sleep(unsigned int ms);。如果没有开启 Asyncify/JSPIsrc/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 -sASYNCIFY 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.jsemscripten_sleep与emscripten_wget_data都以此标记实现emscripten_scan_registers则用__async: true配合Asyncify.handleSleep手动管理暂停细节。兼容旧引擎Asyncify.handleAsync与Asyncify.handleSleep如果目标 JS 引擎不支持现代async/await语法可以把上面do_fetch的实现降级为基于 Promise 的写法——用EM_JS配合Asyncify.handleAsyncEM_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_IMPORTSdo_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_IMPORTSwasi_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_IMPORTSemcc main.cpp libsleep.wasm -O3 -sASYNCIFY -sASYNCIFY_IMPORTSsleep_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.callval(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 -sASYNCIFY 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 时返回值总是Promiselet 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.jsJSPI_IMPORTS中列出的导入函数在执行异步工作时应返回Promise也可以像 JS library 那样用function_name_async: true标记来代替见 src/settings.js。注意使用上述各类辅助机制时EM_ASYNC_JS、Embind 的 Async 支持、ccall等通常不需要手动设置JSPI/ASYNCIFY_IMPORTS与JSPI_EXPORTS这些机制会自动完成登记。优化 Asyncify开销来源与手动调优本节只适用于 Asyncify不适用于 JSPIJSPI 不改写 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()而非_ZN6Struct4FuncEvC 函数名不带参数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 期间有事件触发其他代码修改了这个全局变量就会出问题。在栈上存在编译代码时开始 rewindAsyncify前面的例子都是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.jsASYNCIFY、ASYNCIFY_IMPORTS、ASYNCIFY_IGNORE_INDIRECT、ASYNCIFY_STACK_SIZE、ASYNCIFY_REMOVE/ADD/ONLY/ADVISE、JSPI、JSPI_IMPORTS/JSPI_EXPORTS头文件声明system/include/emscripten/emscripten.hemscripten_sleep、system/include/emscripten/em_js.hEM_ASYNC_JS宏测试用例test/core/test_em_async_js.c、test/core/test_dlfcn_jspi.c、test/core/test_pthread_join_and_asyncify.c其他相关设置可查阅设置参考文档赞分享编译器WebAssembly开发工具构建工具【免费下载链接】emscriptenEmscripten: An LLVM-to-WebAssembly Compiler项目地址https://gitcode.com/gh_mirrors/em/emscripten点击查看免费下载相关推荐终极Flume异步编程指南如何实现同步与异步代码无缝切换终极Flume异步编程指南如何实现同步与异步代码无缝切换 Flume是一个安全高效的多生产者多消费者通道库专为Rust异步编程设计。本文将带你快速掌握FluEmscripten proxying.h 完全指南跨线程任务代理与同步/异步调度机制Emscripten proxying.h 完全指南跨线程任务代理与同步/异步调度机制 本篇技术指南以 Emscripten 官方 API 参考文档 site编译器WebAssembly开发工具构建工具Litestar 同步与异步编程模式完全指南阻塞、线程池与 sync_to_thread 实战Litestar 同步与异步编程模式完全指南阻塞、线程池与 sync_to_thread 实战 Litestar 在几乎所有可行位置同时支持同步与异步可调用对后端Web框架上一篇angular-webpack-starter AOT编译完全指南离线优化与部署最佳实践下一篇Encore故障注入微服务弹性测试的混沌工程实践创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考