通过 WebAssembly 使用异步 Web API

Ingvar Stepanyan
Ingvar Stepanyan

网络上的 I/O API 是异步的,但在大多数系统语言中,它们是同步的。将代码编译为 WebAssembly 时,您需要将一种 API 桥接到另一种 API,而这个桥接就是 Asyncify。在本文中,您将了解何时以及如何使用 Asyncify,以及其后台运作方式。

系统语言中的 I/O

我将从一个简单的 C 语言示例开始。假设您想从文件中读取用户的姓名,并向他们发送“您好,(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 上有各种不同的存储选项可供您映射,例如内存存储(JS 对象)、localStorageIndexedDB、服务器端存储以及新的 File System Access API

不过,其中只有两个 API(内存存储空间和 localStorage)可以同步使用,并且这两个 API 在可存储的内容和存储时长方面限制最为严格。所有其他选项仅提供异步 API。

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

原因在于,Web 一直以来都是单线程的,任何与界面相关的用户代码都必须在与界面相同的线程上运行。它必须与布局、渲染和事件处理等其他重要任务争夺 CPU 时间。您不会希望一段 JavaScript 或 WebAssembly 能够启动“文件读取”操作并阻止所有其他内容(整个标签页,或者在过去是整个浏览器)从几毫秒到几秒钟时间,直到操作结束为止。

相反,代码只能调度 I/O 操作以及在操作完成后要执行的回调。此类回调会在浏览器事件循环的过程中执行。我不会在此详细介绍,但如果您有兴趣了解事件循环的底层工作原理,请参阅任务、微任务、队列和时间表,其中详细介绍了此主题。

简而言之,浏览器会逐一从队列中获取所有代码,从而形成一种无限循环。当某个事件被触发时,浏览器会将相应的处理程序加入队列,并在下一次循环迭代时从队列中取出并执行该处理程序。借助此机制,您只需使用单个线程即可模拟并发性并运行大量并行操作。

关于此机制,需要注意的重要一点是,在自定义 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 时执行其他任务(包括渲染、滚动等)。

最后一个示例:即使是“休眠”这样简单的 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 中更符合惯例的“休眠”版本需要调用 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 是一个宏,可让您像定义 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.handleAsync(),而不是 Asyncify.handleSleep()。然后,您可以传递 async JavaScript 函数并在内部使用 awaitreturn,而无需调度 wakeUp() 回调,从而使代码看起来更加自然和同步,同时不会失去异步 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 完成,然后在 promise 解析完毕后,无缝恢复调用堆栈和状态,并继续执行,就像什么都没发生一样。

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

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

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

这与我之前介绍的 JavaScript 中的 async-await 功能非常相似,但与 JavaScript 中的 async-await 不同,它不需要语言提供任何特殊语法或运行时支持,而是通过在编译时转换普通同步函数来实现。

编译前面所示的异步休眠示例时:

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 专用优化选项,通过将转换仅限于指定函数和/或仅限于直接函数调用来减少开销。运行时性能也会受到轻微影响,但仅限于异步调用本身。不过,与实际工作费用相比,这通常可以忽略不计。

真实演示

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

正如本文开头所述,Web 上的一种存储选项是异步 File System Access API。它可让 Web 应用访问真实的主机文件系统。

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

如果您可以将一个对象映射到另一个对象呢?然后,您可以使用支持 WASI 目标的任何工具链,以任何源语言编译任何应用,并在 Web 上的沙盒中运行该应用,同时仍允许该应用操作真实的用户文件!借助 Asyncify,您可以做到这一点。

在此演示中,我编译了 Rust coreutils crate,并对 WASI 进行了一些小修补,这些修补通过 Asyncify 转换传递,并在 JavaScript 端实现了从 WASI 到文件系统访问 API 的异步绑定。与 Xterm.js 终端组件结合使用后,您可以提供一个真实的 shell,该 shell 在浏览器标签页中运行,并在真实的用户文件上运行,就像实际的终端一样。

您可以访问 https://wasi.rreverser.com/ 查看实时效果。

异步化用例也不局限于计时器和文件系统。您还可以进一步学习,在 Web 上使用更小众的 API。

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

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

不过,这可能是一个适合写在其他博文中的故事。

这些示例展示了 Asyncify 在弥合差距并将各类应用移植到 Web 方面具有多么强大的功能,这让您能够获得跨平台访问权限、沙盒和更好的安全性,而这一切都不会丢失功能。