网络上的 I/O API 是异步的,但在大多数系统语言中都是同步的。将代码编译为 WebAssembly 时,您需要将一种 API 桥接到另一种 API,而 Asyncify 就是这座桥梁。在本文中,您将了解何时以及如何使用 Asyncify,以及其后台运作方式。
系统语言中的 I/O
我将从一个简单的 C 语言示例开始。假设您想从文件中读取用户的姓名,并向他们发送“您好,(username)!”消息:
#include <stdio.h>
int main() {
FILE *stream = fopen("name.txt", "r");
char name[20+1];
size_t len = fread(&name, 1, 20, stream);
name[len] = '\0';
fclose(stream);
printf("Hello, %s!\n", name);
return 0;
}
虽然该示例功能不多,但已经演示了您在任何大小的应用中都会发现的功能:它会从外部读取一些输入,在内部进行处理,然后将输出写回外部。所有此类与外界互动都通过一些通常称为输入-输出函数(也简称为 I/O)的函数进行。
如需从 C 读取名称,您至少需要进行两次重要的 I/O 调用:fopen
用于打开文件,fread
用于从中读取数据。检索到数据后,您可以使用另一个 I/O 函数 printf
将结果输出到控制台。
这些函数乍一看很简单,您无需再三考虑读取或写入数据所涉及的机制。不过,具体情况可能会因环境而异:
- 如果输入文件位于本地驱动器上,应用需要执行一系列内存和磁盘访问操作来定位文件、检查权限、打开文件以供读取,然后按分块读取,直到检索到请求的字节数。这可能非常缓慢,具体取决于磁盘的速度和请求的大小。
- 或者,输入文件可能位于已装载的网络位置,在这种情况下,网络堆栈现在也会参与其中,从而增加每个操作的复杂性、延迟时间和潜在重试次数。
- 最后,即使
printf
也无法保证会将内容输出到控制台,并且可能会重定向到文件或网络位置,在这种情况下,它必须遵循上述相同的步骤。
总而言之,I/O 可能会很慢,您无法通过快速浏览代码来预测特定调用的用时。在该操作运行期间,整个应用将会卡住,对用户无响应。
这也不仅限于 C 或 C++。大多数系统语言以同步 API 的形式呈现所有 I/O。例如,如果您将该示例转换为 Rust,API 可能看起来更简单,但适用相同的原则。您只需进行调用并同步等待其返回结果,而它会执行所有耗时的操作,并最终在单次调用中返回结果:
fn main() {
let s = std::fs::read_to_string("name.txt");
println!("Hello, {}!", s);
}
但是,如果您尝试将其中任何示例编译为 WebAssembly 并将其转换为 Web 代码,会发生什么情况?或者,举个具体例子,“文件读取”操作可以转换为什么?它需要从某个存储空间读取数据。
网络的异步模型
Web 上有各种不同的存储选项可供您映射,例如内存存储(JS 对象)、localStorage
、IndexedDB、服务器端存储以及新的 File System Access API。
不过,其中只有两个 API(内存中存储空间和 localStorage
)可以同步使用,并且这两个 API 在可存储的内容和存储时长方面限制最为严格。所有其他选项仅提供异步 API。
这是在 Web 上执行代码的核心属性之一:任何耗时的操作(包括任何 I/O)都必须是异步的。
原因在于,Web 一直以来都是单线程的,任何与界面相关的用户代码都必须在与界面相同的线程上运行。它必须与布局、渲染和事件处理等其他重要任务争夺 CPU 时间。您不希望一段 JavaScript 或 WebAssembly 代码能够启动“文件读取”操作,并在操作完成之前阻塞所有其他内容(整个标签页,或者过去的整个浏览器)几毫秒到几秒钟。
相反,代码只能调度 I/O 操作以及在操作完成后要执行的回调。此类回调会在浏览器事件循环的过程中执行。我不会在此详细介绍,但如果您有兴趣了解事件循环的底层工作原理,请参阅任务、微任务、队列和时间表,其中详细介绍了此主题。
简而言之,浏览器会从队列中依次取出所有代码段,以一种类似于无限循环的方式运行所有代码段。当某个事件被触发时,浏览器会将相应的处理程序加入队列,并在下一次循环迭代时将其从队列中移出并执行。借助此机制,您只需使用单个线程即可模拟并发性并运行大量并行操作。
关于此机制,需要注意的重要一点是,在自定义 JavaScript(或 WebAssembly)代码执行期间,事件循环会被阻塞,而在此期间,您无法对任何外部处理脚本、事件、I/O 等做出响应。要想返回 I/O 结果,唯一的方法是注册回调、完成代码执行,并将控制权交还给浏览器,以便它继续处理所有待处理的任务。完成 I/O 操作后,您的处理脚本将成为其中一个任务并得到执行。
例如,如果您想使用现代 JavaScript 重写上述示例,并决定从远程网址读取名称,则需要使用 Fetch API 和 async-await 语法:
async function main() {
let response = await fetch("name.txt");
let name = await response.text();
console.log("Hello, %s!", name);
}
尽管看起来是同步的,但在底层,每个 await
实际上都是回调的语法糖:
function main() {
return fetch("name.txt")
.then(response => response.text())
.then(name => console.log("Hello, %s!", name));
}
在经过脱糖处理且更清晰的以下示例中,系统会启动请求,并通过第一个回调订阅响应。浏览器收到初始响应(仅包含 HTTP 标头)后,会异步调用此回调。回调开始使用 response.text()
以文本形式读取正文,并使用另一个回调订阅结果。最后,fetch
检索到所有内容后,会调用最后一个回调,该回调会向控制台输出“Hello, (username)!”。
由于这些步骤的异步性质,原始函数可以在 I/O 调度完毕后立即将控制权返回给浏览器,并让整个界面保持响应状态,以便在后台执行 I/O 操作时执行其他任务(包括渲染、滚动等)。
最后一个示例:即使是“休眠”这样简单的 API(会让应用等待指定秒数),也是一种 I/O 操作:
#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");
当然,您可以非常直接地进行翻译,这样会阻塞当前线程,直到时间耗尽:
console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");
事实上,Emscripten 在“休眠”的默认实现中就是这么做的,但这样做非常低效,会阻塞整个界面,并且在此期间不允许处理任何其他事件。通常,请勿在正式版代码中执行此操作。
相反,JavaScript 中更符合惯例的“休眠”版本需要调用 setTimeout()
并使用处理程序进行订阅:
console.log("A");
setTimeout(() => {
console.log("B");
}, 1000);
所有这些示例和 API 有什么共同之处?在每种情况下,原始系统语言中的惯用代码都使用阻塞 API 进行 I/O,而 Web 的等效示例则使用异步 API。在将代码编译为 Web 代码时,您需要以某种方式在这两种执行模型之间进行转换,而 WebAssembly 目前还没有内置执行此操作的功能。
使用 Asyncify 缩小差距
这正是 Asyncify 的用武之地。Asyncify 是 Emscripten 支持的一个编译时功能,可让您暂停整个程序,并稍后异步恢复该程序。
在 C / C++ 中使用 Emscripten
如果您想使用 Asyncify 为上一个示例实现异步休眠,可以按如下方式操作:
#include <stdio.h>
#include <emscripten.h>
EM_JS(void, async_sleep, (int seconds), {
Asyncify.handleSleep(wakeUp => {
setTimeout(wakeUp, seconds * 1000);
});
});
…
puts("A");
async_sleep(1);
puts("B");
EM_JS
是一个宏,可让您像定义 C 函数一样定义 JavaScript 代码段。在其中,使用函数 Asyncify.handleSleep()
指示 Emscripten 挂起程序,并提供一个 wakeUp()
处理脚本,应在异步操作完成后调用该处理脚本。在上面的示例中,该处理脚本会传递给 setTimeout()
,但也可以在接受回调的任何其他上下文中使用。最后,您可以像调用常规 sleep()
或任何其他同步 API 一样,在任何位置调用 async_sleep()
。
在编译此类代码时,您需要告知 Emscripten 激活 Asyncify 功能。为此,请将 -s ASYNCIFY
和 -s ASYNCIFY_IMPORTS=[func1,
func2]
与可能为异步的函数的数组式列表一起传递。
emcc -O2 \
-s ASYNCIFY \
-s ASYNCIFY_IMPORTS=[async_sleep] \
...
这样,Emscripten 就会知道对这些函数的任何调用都可能需要保存和恢复状态,因此编译器会在这些调用周围注入支持代码。
现在,当您在浏览器中执行此代码时,您会看到预期的流畅输出日志,其中 B 会在 A 之后短暂延迟后出现。
A
B
您还可以从 Asyncify 函数返回值。您需要做的是返回 handleSleep()
的结果,并将结果传递给 wakeUp()
回调。例如,如果您想从远程资源提取数字,而不是从文件读取,则可以使用下面的代码段发出请求、暂停 C 代码,并在检索到响应正文后恢复执行,所有这些操作都像同步调用一样顺畅无阻。
EM_JS(int, get_answer, (), {
return Asyncify.handleSleep(wakeUp => {
fetch("answer.txt")
.then(response => response.text())
.then(text => wakeUp(Number(text)));
});
});
puts("Getting answer...");
int answer = get_answer();
printf("Answer is %d\n", answer);
事实上,对于基于 Promise 的 API(例如 fetch()
),您甚至可以将 Asyncify 与 JavaScript 的 async-await 功能结合使用,而不是使用基于回调的 API。为此,请调用 Asyncify.handleAsync()
,而不是调用 Asyncify.handleSleep()
。然后,您可以传递 async
JavaScript 函数并在内部使用 await
和 return
,而无需调度 wakeUp()
回调,从而使代码看起来更加自然和同步,同时不会失去异步 I/O 的任何优势。
EM_JS(int, get_answer, (), {
return Asyncify.handleAsync(async () => {
let response = await fetch("answer.txt");
let text = await response.text();
return Number(text);
});
});
int answer = get_answer();
等待复杂值
不过,此示例仍然仅限于数字。如果您想实现原始示例(其中我尝试以字符串形式从文件中获取用户的姓名),该怎么办?您也可以这样做!
Emscripten 提供了一项名为 Embind 的功能,可让您处理 JavaScript 和 C++ 值之间的转换。它还支持 Asyncify,因此您可以对外部 Promise
调用 await()
,它将像 async-await JavaScript 代码中的 await
一样运行:
val fetch = val::global("fetch");
val response = fetch(std::string("answer.txt")).await();
val text = response.call<val>("text").await();
auto answer = text.as<std::string>();
使用此方法时,您甚至无需将 ASYNCIFY_IMPORTS
作为编译标志传递,因为它已默认包含在内。
好的,所有这些在 Emscripten 中都运行良好。其他工具链和语言呢?
其他语言的用法
假设您的 Rust 代码中某个位置存在类似的同步调用,您希望将其映射到 Web 上的异步 API。事实证明,您也可以!
首先,您需要通过 extern
代码块(或您所选语言的外部函数语法)将此类函数定义为常规导入项。
extern {
fn get_answer() -> i32;
}
println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);
并将代码编译为 WebAssembly:
cargo build --target wasm32-unknown-unknown
现在,您需要使用用于存储/恢复堆栈的代码对 WebAssembly 文件进行插桩。对于 C/C++,Emscripten 会为我们执行此操作,但此处未使用它,因此该过程需要手动完成。
幸运的是,Asyncify 转换本身完全不依赖于工具链。它可以转换任意 WebAssembly 文件,无论这些文件是由哪个编译器生成的。该转换是作为 Binaryen 工具链的 wasm-opt
优化器的一部分单独提供的,可以按如下方式调用:
wasm-opt -O2 --asyncify \
--pass-arg=asyncify-imports@env.get_answer \
[...]
传递 --asyncify
以启用转换,然后使用 --pass-arg=…
提供以英文逗号分隔的异步函数列表,其中应挂起程序状态并稍后恢复。
剩下的工作就是提供支持运行时代码,以便实际执行此操作(暂停和恢复 WebAssembly 代码)。再次重申,在 C / C++ 的情况下,Emscripten 会包含此代码,但现在您需要自定义 JavaScript 粘合代码来处理任意 WebAssembly 文件。我们为此专门创建了一个库。
您可以在 GitHub 上找到它(网址为 https://github.com/GoogleChromeLabs/asyncify),也可以在 npm 上找到它(名称为 asyncify-wasm
)。
它会模拟标准的 WebAssembly 实例化 API,但在自己的命名空间下。唯一的区别在于,在常规 WebAssembly API 下,您只能将同步函数作为导入项提供,而在 Asyncify 封装容器下,您还可以提供异步导入项:
const { instance } = await Asyncify.instantiateStreaming(fetch('app.wasm'), {
env: {
async get_answer() {
let response = await fetch("answer.txt");
let text = await response.text();
return Number(text);
}
}
});
…
await instance.exports.main();
当您尝试从 WebAssembly 端调用此类异步函数(例如上例中的 get_answer()
)时,该库会检测返回的 Promise
,挂起并保存 WebAssembly 应用的状态,订阅 promise 完成,然后在 promise 解析完毕后,无缝恢复调用堆栈和状态,并继续执行,就像什么都没发生一样。
由于模块中的任何函数都可能会进行异步调用,因此所有导出内容也可能会变为异步,因此它们也会被封装。您可能已经注意到,在上面的示例中,您需要 await
instance.exports.main()
的结果,才能知道执行何时真正完成。
这一切是如何在后台运作的?
当 Asyncify 检测到对某个 ASYNCIFY_IMPORTS
函数的调用时,它会启动异步操作,保存应用的整个状态(包括调用堆栈和任何临时局部变量),然后在该操作完成后,恢复所有内存和调用堆栈,并从同一位置恢复,状态与程序从未停止时相同。
这与我之前介绍的 JavaScript 中的 async-await 功能非常相似,但与 JavaScript 中的 async-await 不同,它不需要语言提供任何特殊语法或运行时支持,而是通过在编译时转换普通同步函数来实现。
编译前面所示的异步休眠示例时:
puts("A");
async_sleep(1);
puts("B");
Asyncify 会获取此代码,并将其转换为大致如下所示的代码(伪代码,实际转换要复杂得多):
if (mode == NORMAL_EXECUTION) {
puts("A");
async_sleep(1);
saveLocals();
mode = UNWINDING;
return;
}
if (mode == REWINDING) {
restoreLocals();
mode = NORMAL_EXECUTION;
}
puts("B");
最初,mode
设置为 NORMAL_EXECUTION
。相应地,在首次执行此类转换后的代码时,系统只会对 async_sleep()
之前的部分进行求值。异步操作调度完毕后,Asyncify 会保存所有局部变量,并通过从每个函数返回到顶部来展开堆栈,以便将控制权交还给浏览器事件循环。
然后,在 async_sleep()
解析后,Asyncify 支持代码会将 mode
更改为 REWINDING
,并再次调用该函数。这次,系统会跳过“正常执行”分支,因为它在上次已经执行了该作业,而我希望避免输出“A”两次,因此系统会直接进入“快退”分支。达到该点后,它会恢复所有存储的局部变量,将模式改回“normal”,并继续执行,就好像代码从未停止一样。
转换费用
遗憾的是,Asyncify 转换并非完全免费,因为它必须注入大量支持代码来存储和恢复所有这些局部变量、在不同模式下浏览调用堆栈等。它会尝试仅修改在命令行上标记为异步的函数以及其任何潜在调用方,但在压缩之前,代码大小开销可能仍会增加约 50%。
这并不理想,但在许多情况下,如果替代方案是完全无法使用该功能或必须对原始代码进行大幅重写,这种做法是可以接受的。
请务必始终为最终 build 启用优化,以免其变得更高。您还可以查看 Asyncify 专用优化选项,通过将转换仅限于指定函数和/或仅限于直接函数调用来减少开销。运行时性能也会受到轻微影响,但仅限于异步调用本身。不过,与实际工作费用相比,这通常可以忽略不计。
真实演示
现在,您已经了解了简单的示例,接下来我将介绍更复杂的场景。
正如本文开头所述,Web 上的一种存储选项是异步 File System Access API。它可让 Web 应用访问真实的主机文件系统。
另一方面,控制台和服务器端有一个名为 WASI 的事实标准,用于 WebAssembly I/O。它被设计为系统语言的编译目标,并以传统同步形式公开各种文件系统和其他操作。
如果您可以将这两者映射到一起,那该多好!然后,您可以使用支持 WASI 目标的任何工具链,以任何源语言编译任何应用,并在 Web 上的沙盒中运行该应用,同时仍允许它操作真实的用户文件!借助 Asyncify,您可以轻松实现这一点。
在此演示中,我编译了 Rust coreutils crate,并对 WASI 进行了一些小修补,这些修补通过 Asyncify 转换传递,并在 JavaScript 端实现了从 WASI 到文件系统访问 API 的异步绑定。与 Xterm.js 终端组件结合使用后,此功能可在浏览器标签页中运行逼真的 Shell,并操作真实的用户文件,就像使用真实的终端一样。
您可以访问 https://wasi.rreverser.com/ 查看实时数据。
异步化用例也不局限于计时器和文件系统。您还可以进一步学习,在 Web 上使用更小众的 API。
例如,借助 Asyncify,您还可以将 libusb(可能是与 USB 设备交互最常用的原生库)映射到 WebUSB API,从而对 Web 上的此类设备进行异步访问。映射和编译完成后,我就可以直接在网页的沙盒中针对所选设备运行标准的 libusb 测试和示例。
不过,这可能是一个适合写在其他博文中的故事。
这些示例展示了 Asyncify 在弥合差距和将各种应用移植到 Web 方面的强大能力,让您能够实现跨平台访问、沙盒化和更强的安全性,而不会丢失任何功能。