Web 上的 I/O API 是异步的,但在大多数系统语言中是同步的。时间 编译代码到 WebAssembly 时,您需要将一种 API 桥接到另一种 API,而这种桥接是 Asyncify。在这篇博文中,您将了解何时以及如何使用 Asyncify,以及它的后台运行方式。
以系统语言表示的 I/O
我先来看一个简单的 C 语言示例。比如说,您想从文件中读取用户的姓名, 并使用“Hello, (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。例如,如果您将示例转换为 Rust,API 可能看起来更简单, 原则同样适用您只需进行调用并同步等待其返回结果, 同时它会执行所有开销大的操作,并最终在单个 调用:
fn main() {
let s = std::fs::read_to_string("name.txt");
println!("Hello, {}!", s);
}
但是,当您尝试将任何示例编译为 WebAssembly 并将其转换为 ?或者,要提供具体示例,“文件读取”操作转换为什么内容?它 需要从某个存储空间读取数据。
网页的异步模型
Web 具有多种不同的存储选项,例如内存中存储 (JS)
对象)、localStorage
、
IndexedDB、服务器端存储
和新的 File System Access API。
不过,其中只有 2 个 API 可供使用:内存中存储和 localStorage
两者都是对您可以存储的内容和存储时长的限制最多。全部
其他选项仅提供异步 API。
这是在 Web 上执行代码的核心属性之一,即任何耗时的操作, 包括任何 I/O 都必须是异步的。
原因在于网页一直以来都是单线程的, 必须与界面在同一线程上运行。它必须与其他重要任务 CPU 时间的布局、渲染和事件处理。你不需要一段 JavaScript 或 WebAssembly 等工具操作并阻止所有其他内容 - 整个标签页、 或过去是整个浏览器(从几毫秒到几秒不等),直到此类事件结束为止。
相反,代码只能同时安排 I/O 操作和要执行的回调 创建完毕。此类回调作为浏览器事件循环的一部分执行。我不会 但如果您有兴趣了解事件循环的底层工作原理, 结账 任务、微任务、队列和时间表 其中对这个主题进行了深入介绍。
简而言之,浏览器以无限循环的形式运行所有代码, 从队列中逐个获取。当某个事件被触发时,浏览器会将 在下一次循环迭代时,将其从队列中删除并执行。 此机制允许模拟并发和运行大量并行操作,而仅使用 单个线程。
关于此机制,请务必注意,虽然您的自定义 JavaScript(或 WebAssembly)代码执行时,事件循环被阻止,当它被执行时,无法对 获取 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)!”添加到
控制台。
由于这些步骤的异步性质,原始函数可以将控制权交还给 浏览器,并使整个界面保持响应状态并随时可用 包括渲染、滚动等在内的其他任务。
最后举一个例子,即便是“sleep”等简单的 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 在其默认实施工具中所做的工作。 “sleep”、 但这样效率很低,会阻止整个界面,不允许处理任何其他事件 。通常,不要在正式版代码中执行此操作。
“睡眠”就是更符合惯例的说法,涉及调用 setTimeout()
;
使用处理程序订阅:
console.log("A");
setTimeout(() => {
console.log("B");
}, 1000);
所有这些示例和 API 的共同点是什么?在这两种情况下,原始语言中的惯用代码都是 系统语言针对 I/O 使用阻塞 API,而针对 Web 的等效示例则使用 异步 API在编译为网页时,您需要在这两种环境之间 执行模型,而 WebAssembly 目前还没有内置的功能来执行这项操作。
利用 Asyncify 弥补缺失
这正是 Asyncify 的用武之地。Asyncify 是 编译时功能(由 Emscripten 支持),允许暂停整个程序并 异步恢复。
通过 Emscripten 使用 C / C++ 语言
对于上一个示例,如果您想使用 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);
事实上,对于 fetch()
等基于 Promise 的 API,您甚至可以将 Asyncify 与 JavaScript 的
async-await 功能,而不是使用基于回调的 API。因此,我们不再将
Asyncify.handleSleep()
,调用 Asyncify.handleAsync()
。然后,您不必再安排
wakeUp()
回调,您可以传递 async
JavaScript 函数并使用 await
和 return
使代码看起来更自然和同步,同时又不会失去
异步 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 中的 await
一样
JavaScript 代码:
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 代码中的某个位置有一个类似的同步调用,并且想要映射到 异步 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 / Emscripten 可以为我们执行此操作,但这里并未使用,因此该过程需要手动完成一些工作。
幸运的是,Asyncify 转换本身完全与工具链无关。它可以将任意
WebAssembly 文件,无论该文件是由哪个编译器生成的。转换单独提供
作为 wasm-opt
优化工具的一部分,来自 Binaryen
工具链,可以按如下方式调用:
wasm-opt -O2 --asyncify \
--pass-arg=asyncify-imports@env.get_answer \
[...]
传递 --asyncify
以启用转换,然后使用 --pass-arg=…
提供以英文逗号分隔的
异步函数的列表,其中应挂起程序状态,然后再恢复。
接下来要做的就是提供能够真正实现此目的的运行时代码 - 挂起和恢复 WebAssembly 代码。同样,在 C / C++ 案例中,这会由 Emscripten 添加,但现在您需要 用于处理任意 WebAssembly 文件的自定义 JavaScript 粘合代码。我们创建了一个库 就是一个很好的例子
您可以在 GitHub 上找到它,网址为:
https://github.com/GoogleChromeLabs/asyncify or 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();
当您尝试从get_answer()
WebAssembly 端,该库将检测返回的 Promise
,挂起并保存
订阅 WebAssembly 应用,订阅 promise 补全,并在解析完成后,
无缝恢复调用堆栈和状态,并像未发生任何事件一样继续执行。
由于模块中的任何函数都可能会进行异步调用,因此所有导出都可能变为
异步执行,因此它们也会被封装。在上面的示例中,您可能已经注意到
需要 await
处理 instance.exports.main()
的结果,以了解何时真正开始执行
已完成。
这背后的工作原理是什么?
当 Asyncify 检测到对其中一个 ASYNCIFY_IMPORTS
函数的调用时,它会启动一个异步函数,
操作会保存应用的整个状态,包括调用堆栈和任何临时
在该操作完成后,恢复所有内存和调用堆栈
从同一位置恢复并保持与程序从未停止相同的状态。
这与我之前介绍的 JavaScript 中的 async-await 功能非常相似,但与 使用 JavaScript 时,该语言不需要任何特殊语法或运行时支持, 其工作原理是在编译时对普通同步函数进行转换。
在编译前面显示的异步休眠示例时:
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”而是会直接进入
“rewinding”分支。达到该上限后,它会恢复所有存储的局部变量,并将模式更改回
“normal”并继续执行,就好像代码从未停止过一样。
转换费用
遗憾的是,Asyncify 转换并非完全免费,因为它必须注入大量 用于存储和恢复所有这些局部变量的支持代码,并在以下位置导航调用堆栈: 等等它只会尝试修改命令中标记为异步的函数 行,以及任何潜在的调用方,但在压缩前,代码大小开销之和可能仍会达到大约 50%。
这并不理想,但在许多情况下,当替代方案不具备相关功能时, 或者必须对原始代码进行大量重写。
确保始终对最终 build 启用优化,以免其性能进一步提升。您可以 另请查看 Asyncify 专有的优化 减少开销 将转换限制为仅适用于指定的函数和/或直接函数调用。还有一个 运行时性能的成本微乎其微,但这仅限于异步调用本身。不过,与 通常可以忽略不计。
真实演示
了解过简单的示例后,我再来看看更复杂的场景。
正如本文开头所述,网络存储方案之一是 异步 File System Access API。通过它,您可以 真实主机文件系统
另一方面,有一种名为 WASI 的标准标准。 在控制台和服务器端分别针对 WebAssembly I/O 集成。它被设计为用于 系统语言,并公开了以传统方式使用的各种文件系统和其他操作, 同步形式。
如果您可以将一个对象映射到另一个对象呢?然后,您可以使用任何源语言编译任何应用 任何支持 WASI 目标的工具链,并在 Web 的沙盒中运行它,同时 让它能够在真实的用户文件上运行!借助 Asyncify,您可以做到这一点。
在本演示中,我使用 对 WASI 进行了一些小补丁,通过 Asyncify 转换进行传递,并实现了异步 来自 WASI 的绑定 在 JavaScript 端使用 File System Access API。与 Xterm.js 终端组件,它提供了一个在 并在真实的用户文件上运行,就像使用真正的终端一样。
欢迎前往 https://wasi.rreverser.com/ 查看实况。
Asyncify 用例不仅仅局限于计时器和文件系统。您可以更进一步 在网络上使用更多小众 API。
例如,也可以借助 Asyncify, libusb - 可能是最适合与 USB 设备 - 到 WebUSB API,此 API 支持异步访问此类设备 。完成映射和编译后,我就可以针对所选的 libusb 测试和示例运行 设置在网页沙盒中
不过,这可能是另一篇博文中的故事。
这些示例展示了 Asyncify 在弥合差距和移植所有 让您可以获得跨平台访问、沙盒以及更好的 安全性,而且不会失去任何功能。