Web 上的 I/O API 是异步的,但在大多数系统语言中是同步的。将代码编译为 WebAssembly 时,您需要将一种 API 桥接到另一种 API,而此桥接就是 Asyncify。在这篇博文中,您将了解何时以及如何使用 Asyncify,以及它在幕后是如何运作的。
以系统语言进行输入/输出
我将先从一个简单的 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 的形式呈现所有 I/O。例如,如果您将该示例翻译为 Rust,API 看起来可能会更简单,但仍遵循相同的原则。您只需进行一次调用,然后同步等待其返回结果,而它会执行所有开销大的操作,并最终在一次调用中返回结果:
fn main() {
let s = std::fs::read_to_string("name.txt");
println!("Hello, {}!", s);
}
但是,如果您尝试将这些示例编译为 WebAssembly 并将其转换为 Web,会发生什么情况?或者,举个具体示例,“文件读取”操作可以转换为哪些操作?它需要从某个存储空间读取数据。
Web 的异步模型
Web 提供了多种不同的存储选项,您可以将它们映射到内存中存储空间(JS 对象)、localStorage、IndexedDB、服务器端存储空间和新的 File System Access API。
不过,这些 API 中只有两个(内存中存储和 localStorage)可以同步使用,而且这两个 API 在存储内容和存储时长方面都是限制最多的选项。所有其他选项仅提供异步 API。
这是在 Web 上执行代码的核心属性之一:任何耗时的操作(包括任何 I/O)都必须是异步的。
原因是,从历史上看,Web 是单线程的,任何触及界面的用户代码都必须与界面在同一线程上运行。它必须与布局、渲染和事件处理等其他重要任务竞争 CPU 时间。您肯定不希望一段 JavaScript 或 WebAssembly 代码能够启动“文件读取”操作,并阻止其他所有操作(整个标签页,或者在过去是整个浏览器)运行,直到该操作结束,而这可能需要几毫秒到几秒的时间。
相反,代码只能安排 I/O 操作以及在 I/O 操作完成后执行的回调。此类回调作为浏览器事件循环的一部分执行。我不会在此处详细介绍,但如果您有兴趣了解事件循环 (event loop) 在幕后是如何运作的,请参阅任务、微任务、队列和调度,其中深入介绍了此主题。
简而言之,浏览器会从队列中逐个提取代码片段,并在某种无限循环中运行所有代码片段。当某个事件被触发时,浏览器会将相应的处理程序排入队列,并在下一个循环迭代中将其从队列中取出并执行。此机制允许在仅使用单个线程的情况下模拟并发并运行大量并行操作。
关于此机制,需要记住的重要一点是,在自定义 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。
再举一个最后的例子,即使是像“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() -