通过 WebAssembly 使用异步 Web API

Ingvar Stepanyan
Ingvar Stepanyan

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) 对象)、localStorageIndexedDB、服务器端存储 和新的 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 支持),允许暂停整个程序并 异步恢复。

调用图
来描述 JavaScript ->WebAssembly ->网络 API ->异步任务调用,其中 Asyncify 将
将异步任务的结果传回 WebAssembly

通过 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 函数并使用 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 中的 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%。

显示代码的图表
各种基准的大小开销,从微调条件下接近 0%,在最差的情况下超过 100%
案例

这并不理想,但在许多情况下,当替代方案不具备相关功能时, 或者必须对原始代码进行大量重写。

确保始终对最终 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 测试和示例运行 设置在网页沙盒中

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

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

这些示例展示了 Asyncify 在弥合差距和移植所有 让您可以获得跨平台访问、沙盒以及更好的 安全性,而且不会失去任何功能。