通过 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。

这是在 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 有什么共同之处?在每种情况下,原始系统语言中的惯用代码都针对 I/O 使用阻塞 API,而针对 Web 的等效示例则使用异步 API。在将代码编译为 Web 代码时,您需要以某种方式在这两种执行模型之间进行转换,而 WebAssembly 目前还没有内置执行此操作的功能。

使用 Asyncify 缩小差距

这时,Asyncify 就派上用场了。AsyncifyAsyncify 是 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 文件,无论这些文件是由哪个编译器生成的。该转换作为 wasm-opt 优化工具的一部分从 Binaryen 工具链中单独提供,可以按如下方式调用:

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 补全项,然后在问题解析后无缝恢复调用堆栈和状态,并继续执行,就像未发生任何事件一样。

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

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

当 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”两次,因此系统会直接进入“快退”分支。达到该点后,它会恢复所有存储的局部变量,将模式改回“normal”,并继续执行,就像代码从未停止一样。

转换费用

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

显示各种基准测试的代码大小开销的图表,从精调条件下的近 0% 到最糟糕情况下的超过 100%

这并不理想,但在许多情况下,当替代方案不能完全提供相应功能或必须对原始代码进行大量重写时,才可以接受。

确保始终对最终 build 启用优化,以免其性能进一步提升。您还可以选中特定于 Asyncify 的优化选项,通过将转换限制为仅应用于指定函数和/或直接函数调用来减少开销。运行时性能也会受到轻微影响,但仅限于异步调用本身。不过,与实际工作的成本相比,这通常可以忽略不计。

真实演示

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

如本文开头所述,异步 File System Access API 是 Web 上的一种存储方案之一。它提供了从 Web 应用访问真实主机文件系统的权限。

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

如果您可以将这两者映射到一起,那该多好!然后,您可以使用支持 WASI 目标的任何工具链编译采用任何源语言的任何应用,并在 Web 沙盒中运行该应用,同时仍允许该应用在真实的用户文件上运行!借助 Asyncify,您可以轻松实现这一点。

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

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

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

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

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

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

这些示例展示了 Asyncify 在弥合差距和将各种应用移植到 Web 方面的强大能力,让您能够实现跨平台访问、沙盒化和更强的安全性,而不会丢失任何功能。