使用 WebAssembly 中的异步 Web API

Ingvar Stepanyan
Ingvar Stepanyan

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 对象)、localStorageIndexedDB、服务器端存储空间和新的 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() - start < 1000;);
console.log("B");

事实上,Emscripten 在其“休眠”的默认实现中正是这样做的,但这非常低效,会阻塞整个界面,并且不允许在此期间处理任何其他事件。一般来说,请勿在生产代码中执行此操作。

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

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

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

使用 Asyncify 弥合差距

这时,Asyncify 就派上用场了。Asyncify 是 Emscripten 支持的编译时功能,可用于暂停整个程序,并在稍后异步恢复该程序。

一个调用图,描述了 JavaScript -> WebAssembly -> Web API -> 异步任务调用,其中 Asyncify 将异步任务的结果连接回 WebAssembly

在 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 是一种宏,可用于定义 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);

事实上,对于基于 Promise 的 API(例如 fetch()),您甚至可以将 Asyncify 与 JavaScript 的 async-await 功能结合使用,而不是使用基于回调的 API。为此,请调用 Asyncify.handleAsync(),而不是 Asyncify.handleSleep()。这样一来,您无需再安排 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 会为我们执行此操作,但此处未使用 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 解析后,无缝恢复调用堆栈和状态,并继续执行,就像什么都没发生过一样。

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

这一切是如何在后台运作的?

当 Asyncify 检测到对某个 ASYNCIFY_IMPORTS 函数的调用时,它会启动异步操作,保存应用的整个状态(包括调用堆栈和任何临时局部变量),然后在该操作完成后,恢复所有内存和调用堆栈,并从与程序从未停止时相同的位置和状态继续执行。

这与我之前展示的 JavaScript 中的异步-等待功能非常相似,但与 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”),而是直接进入“回放”分支。到达该断点后,它会恢复所有存储的局部变量,将模式更改回“正常”,并继续执行,就好像代码从未停止过一样。

转型费用

遗憾的是,Asyncify 转换并非完全免费,因为它必须注入相当多的支持代码,用于存储和恢复所有这些局部变量、在不同模式下浏览调用堆栈等。它会尝试仅修改命令行中标记为异步的函数及其任何潜在的调用方,但压缩前的代码大小开销仍可能高达约 50%。

一张图表,显示了各种基准测试的代码大小开销,从经过精细调整的条件下的接近 0% 到最坏情况下的超过 100%

这虽然不是理想的解决方案,但在以下情况下,这种做法在很多情况下都是可以接受的:完全没有相应功能,或者必须对原始代码进行重大重写。

请务必始终为最终 build 启用优化,以免内存使用量进一步增加。您还可以查看 Asyncify 特定的优化选项,通过仅将转换限制为指定函数和/或仅直接函数调用来减少开销。运行时性能也会有少量损失,但仅限于异步调用本身。不过,与实际工作的成本相比,这通常可以忽略不计。

真实演示

现在,您已经了解了简单的示例,接下来我将介绍更复杂的场景。

如本文开头所述,网页上的一种存储选项是异步 File System Access 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 的使用场景也不仅限于计时器和文件系统。您还可以进一步在网络上使用更多小众 API。

例如,借助 Asyncify,还可以将 libusb(可能是最受欢迎的用于处理 USB 设备的原生库)映射到 WebUSB API,从而在 Web 上以异步方式访问此类设备。映射和编译完成后,我就可以在网页的沙盒中针对所选设备运行标准 libusb 测试和示例。

网页上 libusb 调试输出的屏幕截图,显示了有关已连接的 Canon 相机的信息

不过,这可能要另写一篇博文才能讲清楚。

这些示例展示了 Asyncify 在弥合差距和将各种应用移植到 Web 上方面的强大功能,让您能够获得跨平台访问、沙盒和更好的安全性,同时不会损失任何功能。