从 WebAssembly 使用异步 Web API

Web 上的 I/O API 是异步的,但在大多数系统语言中它们是同步的。当将代码编译为 WebAssembly 时,你需要将一种 API 桥接到另一种 API——而这个桥梁就是 Asyncify。在这篇文章中,你将学习何时以及如何使用 Asyncify,以及它的底层工作原理。

系统语言中的 I/O

我将从一个简单的 C 语言示例开始。假设你想从文件中读取用户名,并向他们发送一条“你好,(用户名)!”的消息

#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、服务器端存储以及新的 文件系统访问 API

但是,这些 API 中只有两个——内存存储和 localStorage——可以同步使用,并且两者都是在你可以存储的内容和存储时间方面限制最多的选项。所有其他选项仅提供异步 API。

这是在 web 上执行代码的核心属性之一:任何耗时的操作,包括任何 I/O,都必须是异步的。

原因是 web 在历史上是单线程的,并且任何接触 UI 的用户代码都必须与 UI 在同一线程上运行。它必须与其他重要任务(如布局、渲染和事件处理)竞争 CPU 时间。你不会希望一段 JavaScript 或 WebAssembly 能够启动“文件读取”操作并阻止其他一切——整个选项卡,或者在过去,整个浏览器——持续几毫秒到几秒钟,直到完成。

相反,代码只允许调度 I/O 操作,并附带一个回调,以便在完成后执行。这些回调作为浏览器事件循环的一部分执行。我不会在这里详细介绍,但如果你有兴趣了解事件循环在底层是如何工作的,请查看 Tasks, microtasks, queues and schedules,其中深入解释了这个主题。

简短的版本是,浏览器通过从队列中逐个获取代码片段,以类似无限循环的方式运行所有代码片段。当某些事件被触发时,浏览器将相应的处理程序排队,并在下一个循环迭代中将其从队列中取出并执行。这种机制允许在仅使用单线程的情况下模拟并发并运行大量并行操作。

关于这种机制,要记住的重要一点是,当你的自定义 JavaScript(或 WebAssembly)代码执行时,事件循环被阻塞,并且当它被阻塞时,无法对任何外部处理程序、事件、I/O 等做出反应。取回 I/O 结果的唯一方法是注册一个回调,完成执行你的代码,并将控制权交还给浏览器,以便它可以继续处理任何挂起的任务。一旦 I/O 完成,你的处理程序将成为这些任务之一并将被执行。

例如,如果你想用现代 JavaScript 重写上面的示例,并决定从远程 URL 读取名称,你将使用 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 检索到所有内容,它将调用最后一个回调,该回调将“你好,(用户名)!”打印到控制台。

由于这些步骤的异步性质,原始函数可以在 I/O 被调度后立即将控制权返回给浏览器,并使整个 UI 保持响应并可用于其他任务,包括渲染、滚动等等,而 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() - start < 1000;);
console.log("B");

事实上,这正是 Emscripten 在 其“sleep”的默认实现中所做的,但这非常低效,会阻塞整个 UI,并且不允许同时处理任何其他事件。通常,不要在生产代码中这样做。

相反,JavaScript 中更符合语言习惯的“sleep”版本将涉及调用 setTimeout(),并使用处理程序进行订阅

console.log("A");
setTimeout(() => {
    console.log("B");
}, 1000);

所有这些示例和 API 的共同点是什么?在每种情况下,原始系统语言中符合语言习惯的代码都使用阻塞 API 进行 I/O,而 web 的等效示例则使用异步 API。当编译到 web 时,你需要以某种方式在这两种执行模型之间进行转换,而 WebAssembly 目前还没有内置的能力来做到这一点。

使用 Asyncify 弥合差距

这就是 Asyncify 的用武之地。Asyncify 是 Emscripten 支持的一种编译时功能,它允许暂停整个程序并在以后异步恢复它。

A call graph
describing a JavaScript -> WebAssembly -> web API -> async task invocation, where Asyncify connects
the result of the async task back into WebAssembly

在 C / C++ 中使用 Emscripten

如果你想使用 Asyncify 为上一个示例实现异步 sleep,你可以这样做

#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 是一个宏,允许定义 JavaScript 代码片段,就像它们是 C 函数一样。在内部,使用函数 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 函数并在内部使用 awaitreturn,使代码看起来更加自然和同步,同时又不失异步 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 完成,然后,一旦它被解析,无缝地恢复调用堆栈和状态,并继续执行,就好像什么都没发生过一样。

由于模块中的任何函数都可能进行异步调用,因此所有导出也可能变为异步,因此它们也被包装。你可能已经在上面的示例中注意到,你需要 await instance.exports.main() 的结果才能知道执行何时真正完成。

这一切在底层是如何工作的?

当 Asyncify 检测到对 ASYNCIFY_IMPORTS 函数之一的调用时,它会启动异步操作,保存应用程序的整个状态,包括调用堆栈和任何临时局部变量,然后在操作完成后,恢复所有内存和调用堆栈,并从同一位置和同一状态恢复,就好像程序从未停止过一样。

这与我之前展示的 JavaScript 中的 async-await 功能非常相似,但是,与 JavaScript 不同,它不需要来自语言的任何特殊语法或运行时支持,而是通过在编译时转换纯同步函数来工作。

当编译之前显示的异步 sleep 示例时

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%。

A graph showing code
size overhead for various benchmarks, from near-0% under fine-tuned conditions to over 100% in worst
cases

这并不理想,但在许多情况下是可以接受的,因为替代方案是完全没有该功能或不得不对原始代码进行重大重写。

确保始终为最终版本启用优化,以避免它变得更高。你还可以查看 Asyncify 特定的优化选项,通过仅将转换限制为指定的函数和/或仅直接函数调用来减少开销。运行时性能也有少量成本,但这仅限于异步调用本身。但是,与实际工作的成本相比,它通常可以忽略不计。

真实世界的演示

现在你已经看过了简单的示例,我将继续介绍更复杂的场景。

正如文章开头提到的,web 上的存储选项之一是异步的 文件系统访问 API。它提供了从 web 应用程序访问真实主机文件系统的权限。

另一方面,对于控制台和服务器端的 WebAssembly I/O,有一个事实上的标准称为 WASI。它被设计为系统语言的编译目标,并以传统的同步形式公开各种文件系统和其他操作。

如果你可以将一个映射到另一个会怎么样?那么你可以使用任何支持 WASI 目标的工具链以任何源语言编译任何应用程序,并在 web 上的沙箱中运行它,同时仍然允许它操作真实的用户文件!使用 Asyncify,你可以做到这一点。

在这个演示中,我编译了 Rust coreutils crate,并对 WASI 进行了一些小的修补,通过 Asyncify 转换传递,并在 JavaScript 端实现了从 WASI 到文件系统访问 API 的异步 绑定。一旦与 Xterm.js 终端组件结合使用,这将提供一个在浏览器选项卡中运行并操作真实用户文件的逼真 shell——就像一个实际的终端一样。

https://wasi.rreverser.com/ 上在线查看。

Asyncify 用例也不仅限于计时器和文件系统。你可以更进一步,在 web 上使用更小众的 API。

例如,同样在 Asyncify 的帮助下,可以将 libusb(可能是最流行的用于处理 USB 设备的本机库)映射到 WebUSB API,后者提供对 web 上此类设备的异步访问。一旦映射和编译,我就让标准的 libusb 测试和示例在网页沙箱中针对选定的设备运行。

Screenshot of libusb
debug output on a web page, showing information about the connected Canon camera

不过,这可能是另一篇博文的故事了。

这些示例证明了 Asyncify 在弥合差距并将各种应用程序移植到 web 上是多么强大,使你能够获得跨平台访问、沙箱和更好的安全性,所有这些都不会损失功能。