通过 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 的形式呈现所有 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(内存存储 API 和 localStorage API)可以同步使用,并且这两个 API 都是限制您可以存储的内容和存储时长的最限制选项。所有其他选项仅提供异步 API。

这是在网页上执行代码的核心属性之一:任何耗时的操作(包括任何 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 在后台运行时使整个界面保持响应,并可用于其他任务(包括渲染、滚动等)。

最后,即使是让应用等待指定秒数的“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”实现中执行的操作,但是效率非常低下,会阻塞整个界面,并且不允许同时处理任何其他事件。通常,不要在生产代码中执行此操作。

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

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

所有这些示例和 API 的共同点是什么?在每种情况下,原始系统语言的惯用代码都会为 I/O 使用阻塞 API,而针对 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(),但可以在接受回调的任何其他上下文中使用。最后,您可以在任何所需位置调用 async_sleep(),就像常规 sleep() 或任何其他同步 API 一样。

编译此类代码时,您需要告知 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 会为我们执行此操作,但此处并未用到它,因此该过程需要更多手动操作。

幸运的是,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 或 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 完成,并在问题解决后无缝恢复调用堆栈和状态,并继续执行,就像什么也没有发生一样。

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

这背后的工作原理是怎样的?

当 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”两次,而是直接进入“回退”分支。到达该区域后,它会恢复存储的所有局部代码,将模式更改为“正常”,然后继续执行,就好像代码从未停止过一样。

转型费用

遗憾的是,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 到 File System Access API 的异步绑定。与 Xterm.js 终端组件结合使用后,即可提供一个在浏览器标签页中运行的真实 shell,并像实际终端一样对真实用户文件执行操作。

欢迎访问 https://wasi.rreverser.com/ 实时访问该版本。

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

例如,借助 Asyncify,还可以将 libusb(可能是与 USB 设备搭配使用最常用的原生库)映射到 WebUSB API,后者可提供对网络上的此类设备的异步访问。完成映射和编译后,我就得到了标准的 libusb 测试和示例,这些测试和示例可以直接在网页的沙盒中针对所选设备运行。

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

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

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