WebAssembly 线程支持是 WebAssembly 最重要的性能补充之一。它允许您在单独的内核上并行运行部分代码,或者在输入数据的独立部分上运行相同的代码,将其扩展到与用户拥有的内核数量一样多,并显着减少整体执行时间。在本文中,您将学习如何使用 WebAssembly 线程将用 C、C++ 和 Rust 等语言编写的多线程应用程序带到 Web。 WebAssembly 线程不是一个单独的功能,而是多个组件的组合,允许 WebAssembly 应用程序在网络上使用传统的多线程范例。第一个组件是您从 JavaScript 中了解和喜爱的常规 Worker。 WebAssembly 线程使用新的 Worker 构造函数来创建新的底层线程。每个线程加载一个 JavaScript 胶水,然后主线程使用 Worker#postMessage 方法与其他线程共享已编译的 WebAssembly.Module 以及共享的 WebAssembly.Memory(见下文)。这建立了通信并允许所有这些线程在相同的共享内存上运行相同的 WebAssembly 代码,而无需再次通过 JavaScript。 Web Workers 已经存在十多年了,得到了广泛的支持,并且不需要任何特殊的标志。 WebAssembly 内存由 JavaScript API 中的 WebAssembly.Memory 对象表示。默认情况下,WebAssembly.Memory 是 ArrayBuffer 的包装器——一个只能由单个线程访问的原始字节缓冲区。为了支持多线程,WebAssembly.Memory 也获得了一个共享变体。当通过 JavaScript API 或由 WebAssembly 二进制文件本身使用共享标志创建时,它变成了 SharedArrayBuffer 的包装器。它是 ArrayBuffer 的一种变体,可以与其他线程共享并从任何一方同时读取或修改。
与 postMessage 不同,通常用于主线程和 Web Workers 之间的通信,SharedArrayBuffer 不需要复制数据,甚至不需要等待事件循环发送和接收消息。相反,所有线程几乎可以立即看到任何更改,这使其成为传统同步原语更好的编译目标。 SharedArrayBuffer 有着复杂的历史。它最初在 2017 年中期在多个浏览器中发布,但由于发现了 Spectre 漏洞而不得不在 2018 年初被禁用。特殊原因是 Spectre 中的数据提取依赖于计时攻击——测量特定代码段的执行时间。为了使这种攻击更加困难,浏览器降低了 Date.now 和 performance.now 等标准计时 API 的精度。然而,共享内存与在单独线程中运行的简单计数器循环相结合也是获得高精度计时的一种非常可靠的方法,并且在不显着限制运行时性能的情况下更难缓解。取而代之的是,Chrome 68(2018 年中)通过利用站点隔离再次重新启用 SharedArrayBuffer,该功能将不同的网站置于不同的进程中,并使使用像 Spectre 这样的旁道攻击变得更加困难。但是,这种缓解措施仍然仅限于 Chrome 桌面,因为站点隔离是一项相当昂贵的功能,并且无法默认为低内存移动设备上的所有站点启用,其他供应商也尚未实施。快进到 2020 年,Chrome 和 Firefox 都实现了站点隔离,并且是网站通过 COOP 和 COEP 标头选择加入该功能的标准方式。选择加入机制允许在低功率设备上使用站点隔离,因为为所有网站启用它的成本太高。要选择加入,请在服务器配置的主文档中添加以下标头:一旦选择加入,您就可以访问 SharedArrayBuffer(包括由 SharedArrayBuffer 支持的 WebAssembly.Memory)、精确计时器、内存测量和其他需要的 API出于安全原因,一个孤立的来源。查看使用 COOP 和 COEP 使您的网站“跨域隔离”了解更多详细信息。虽然 SharedArrayBuffer 允许每个线程读取和写入相同的内存,但为了正确通信,您需要确保它们不会同时执行冲突操作。例如,一个线程可能开始从共享地址读取数据,而另一个线程正在写入数据,因此第一个线程现在将获得损坏的结果。此类错误称为竞争条件。为了防止竞争条件,您需要以某种方式同步这些访问。这就是原子操作的用武之地。 WebAssembly 原子是 WebAssembly 指令集的扩展,它允许“原子地”读取和写入小型数据单元(通常是 32 位和 64 位整数)。也就是说,以某种方式保证没有两个线程同时读取或写入同一个单元格,从而在低级别防止此类冲突。此外,WebAssembly 原子包含另外两种指令类型——“wait”和“notify”——它们允许一个线程在共享内存中的给定地址上休眠(“wait”),直到另一个线程通过“notify”将其唤醒。
所有更高级别的同步原语,包括通道、互斥锁和读写锁都建立在这些指令之上。 WebAssembly 原子和 SharedArrayBuffer 是相对较新的功能,尚未在所有支持 WebAssembly 的浏览器中可用。您可以在 webassembly.org 路线图上找到哪些浏览器支持新的 WebAssembly 功能。为了确保所有用户都可以加载您的应用程序,您需要通过构建两个不同版本的 Wasm 来实现渐进式增强——一个支持多线程,一个不支持多线程。然后根据特征检测结果加载支持的版本。要在运行时检测 WebAssembly 线程支持,请使用 wasm-feature-detect 库并像这样加载模块: import { threads } from 'wasm-feature-detect' ; const hasThreads = 等待线程(); const module = await ( hasThreads ? import ( './module-with-threads.js' ) : import ( './module-without-threads.js' ) ) ; // ...现在像往常一样使用`module` 现在让我们看看如何构建WebAssembly 模块的多线程版本。在 C 中,尤其是在类 Unix 系统上,使用线程的常用方法是通过 pthread 库提供的 POSIX 线程。 Emscripten 提供了构建在 Web Workers、共享内存和原子之上的 pthread 库的 API 兼容实现,因此相同的代码可以在 Web 上运行而无需更改。 #include <stdio.h> #include <unistd.h> #include <pthread.h> void * thread_callback ( void *arg ) { sleep ( 1 ) ; printf ( "线程内部:%d\n" , * ( int * )arg ) ;返回 NULL ; } int main() { puts ("线程前");线程 ID ;整数 arg = 42 ; pthread_create ( &thread_id , NULL , thread_callback , &arg ) ; pthread_join (thread_id , NULL ) ; puts("线程之后");返回 0 ; }
此处通过 pthread.h 包含 pthread 库的头文件。您还可以看到几个处理线程的关键函数。 pthread_create 将创建一个后台线程。它需要一个目标来存储线程句柄、一些线程创建属性(这里没有传递任何属性,所以它只是 NULL)、要在新线程中执行的回调(这里是 thread_callback),以及一个可选的参数指针来传递给它回调以防您想从主线程共享一些数据——在这个例子中,我们共享一个指向变量 arg 的指针。稍后可以随时调用 pthread_join 等待线程完成执行,并获取回调返回的结果。它接受先前分配的线程句柄以及存储结果的指针。在这种情况下,没有任何结果,因此该函数将 NULL 作为参数。要使用 Emscripten 使用线程编译代码,您需要调用 emcc 并传递一个 -pthread 参数,就像在其他平台上使用 Clang 或 GCC 编译相同的代码一样:但是,当您尝试在浏览器或 Node.js 中运行它时,您将看到一个警告,然后程序将挂起:在线程之前尝试生成一个新线程,但线程池已耗尽。这可能会导致死锁,除非某些线程最终退出或代码明确跳出到事件循环。如果要增加池大小,请使用设置`-s PTHREAD_POOL_SIZE=...`。如果你想抛出一个明确的错误而不是在这些情况下死锁的风险,使用设置`-s PTHREAD_POOL_SIZE_STRICT=2`。 [……挂在这里……] 发生了什么事?问题是,Web 上大部分耗时的 API 都是异步的,并且依赖于事件循环来执行。与传统环境相比,这种限制是一个重要的区别,在传统环境中,应用程序通常以同步、阻塞的方式运行 I/O。如果您想了解更多信息,请查看关于使用来自 WebAssembly 的异步 Web API 的博客文章。
在这种情况下,代码同步调用 pthread_create 来创建后台线程,然后同步调用 pthread_join 以等待后台线程完成执行。但是,使用 Emscripten 编译此代码时在幕后使用的 Web Workers 是异步的。所以发生的事情是, pthread_create 只安排一个新的 Worker 线程在下一次事件循环运行时创建,但是 pthread_join 立即阻塞事件循环以等待该 Worker,并且这样做可以防止它被创建。这是死锁的典型例子。解决这个问题的一种方法是在程序开始之前提前创建一个工人池。当 pthread_create 被调用时,它可以从池中取出一个随时可用的 Worker,在其后台线程上运行提供的回调,并将 Worker 返回到池中。所有这些都可以同步完成,所以只要池足够大就不会出现任何死锁。这正是 Emscripten 通过 -s PTHREAD_POOL_SIZE=... 选项所允许的。它允许指定多个线程 - 一个固定的数量,或者像 navigator.hardwareConcurrency 这样的 JavaScript 表达式来创建与 CPU 上的内核一样多的线程。当您的代码可以扩展到任意数量的线程时,后一个选项很有用。在上面的示例中,只创建了一个线程,因此不必保留所有内核,而是使用 -s PTHREAD_POOL_SIZE=1 就足够了: 但是还有另一个问题:在代码示例中看到 sleep(1) 了吗?它在线程回调中执行,意味着脱离主线程,所以应该没问题,对吧?好吧,它不是。当 pthread_join 被调用时,它必须等待线程执行完成,这意味着如果创建的线程正在执行长时间运行的任务——在这种情况下,休眠 1 秒——那么主线程也将不得不阻塞相同的数量直到结果回来的时间。在浏览器中执行此 JS 时,它将阻塞 UI 线程 1 秒,直到线程回调返回。这会导致糟糕的用户体验。首先,如果你只需要在主线程之外运行一些任务,而不需要等待结果,你可以使用 pthread_detach 而不是 pthread_join。这将使线程回调在后台运行。如果您使用此选项,您可以使用 -s PTHREAD_POOL_SIZE_STRICT=0 关闭警告。
其次,如果您正在编译 C 应用程序而不是库,则可以使用 -s PROXY_TO_PTHREAD 选项,除了应用程序本身创建的任何嵌套线程之外,该选项还将主应用程序代码卸载到单独的线程。这样,主代码可以随时安全地阻塞,而不会冻结 UI。顺便说一下,当使用这个选项时,你也不必预先创建线程池——相反,Emscripten 可以利用主线程来创建新的底层 Worker,然后在 pthread_join 中阻塞辅助线程而不会死锁。第三,如果您正在处理一个库并且仍然需要阻塞,您可以创建自己的 Worker,导入 Emscripten 生成的代码并使用 Comlink 将其公开给主线程。主线程将能够调用任何导出的方法作为异步函数,这样也可以避免阻塞 UI。在像前面的示例这样的简单应用程序中,-s PROXY_TO_PTHREAD 是最佳选择:所有相同的警告和逻辑都以相同的方式应用于 C++。您获得的唯一新东西是访问更高级别的 API,例如 std::thread 和 std::async,它们在后台使用了前面讨论过的 pthread 库。 #include <iostream> #include <thread> #include <chrono> int main ( ) { puts ( "Before the thread" ) ;整数 arg = 42 ; std ::thread thread ( [ & ] ( ) { std ::this_thread :: sleep_for (std ::chrono :: seconds ( 1 ) ) ; std ::cout << "线程内部:" << arg << std ::结束; } ) ;线 。加入 ( ) ; std::cout << "线程之后" << std ::endl ;返回 0 ; } 使用类似参数编译和执行时,它的行为方式与 C 示例相同: Before the thread 线程内部:42 Pthread 0xc06190 exited。在线程代理主线程 0xa05c18 完成后返回代码 0. EXIT_RUNTIME=0 设置,因此保持主线程处于活动状态以进行异步事件操作。线程 0xa05c18 退出。
与 Emscripten 不同,Rust 没有专门的端到端 Web 目标,而是为通用 WebAssembly 输出提供通用的 wasm32-unknown-unknown 目标。如果 Wasm 旨在用于 Web 环境,则与 JavaScript API 的任何交互都留给外部库和工具,如 wasm-bindgen 和 wasm-pack。不幸的是,这意味着标准库不知道 Web Workers 和标准 API,例如 std::thread 在编译为 WebAssembly 时将无法工作。幸运的是,生态系统的大部分都依赖于更高级别的库来处理多线程。在那个级别,抽象出所有平台差异要容易得多。特别是,Rayon 是 Rust 中最流行的数据并行选择。它允许您在常规迭代器上采用方法链,并且通常只需更改一行,就可以在所有可用线程上并行运行而不是按顺序运行的方式转换它们。例如: pub fn sum_of_squares (numbers : & [ i32 ] ) -> i32 { numbers .迭代 ( ) 。 par_iter()。地图 ( |x | x * x ) 。 sum ( ) } 有了这个小改动,代码将拆分输入数据,在并行线程中计算 x * x 和部分和,最后将这些部分结果加在一起。为了适应没有工作 std::thread 的平台,Rayon 提供了允许定义用于生成和退出线程的自定义逻辑的钩子。
wasm-bindgen-rayon 利用这些钩子来生成 WebAssembly 线程作为 Web Workers。要使用它,您需要将其添加为依赖项并按照文档中描述的配置步骤进行操作。上面的例子最终看起来像这样: pub use init_thread_pool ; pub fn sum_of_squares (numbers : & [ i32 ] ) -> i32 { numbers . par_iter()。地图 ( |x | x * x ) 。 sum ( ) } 完成后,生成的 JavaScript 将导出一个额外的 initThreadPool 函数。此函数将创建一个Worker 池,并在程序的整个生命周期中为 Rayon 完成的任何多线程操作重用它们。这种池机制类似于前面解释过的 Emscripten 中的 -s PTHREAD_POOL_SIZE=... 选项,也需要在主代码之前进行初始化以避免死锁: import init , { initThreadPool , sum_of_squares } from './pkg/index. js'; // 常规 wasm-bindgen 初始化。等待初始化(); // 使用给定线程数初始化线程池 //(如果要使用所有内核,请通过 `navigator.hardwareConcurrency`)。等待 initThreadPool (navigator .hardwareConcurrency ) ; // ...现在您可以像往常一样调用任何导出的函数。 log(sum_of_squares(new([1,2,3]))); // 14 请注意,关于阻塞主线程的相同警告也适用于此。即使 sum_of_squares 示例仍然需要阻塞主线程以等待来自其他线程的部分结果。根据迭代器的复杂性和可用线程的数量,它可能是一个很短的等待或一个很长的等待,但是,为了安全起见,浏览器引擎会主动防止完全阻塞主线程,这样的代码会抛出错误。相反,您应该创建一个 Worker,在那里导入 wasm-bindgen 生成的代码,并使用 Comlink 之类的库将其 API 暴露给主线程。
我们在 Squoosh.app 中积极使用 WebAssembly 线程进行客户端图像压缩——特别是对于 AVIF (C++)、JPEG-XL (C++)、OxiPNG (Rust) 和 WebP v2 (C++) 等格式。多亏了多线程,我们已经看到了一致的 1.5x-3x 加速(每个编解码器的确切比率不同),并且能够通过将 WebAssembly 线程与 WebAssembly SIMD 结合来进一步推动这些数字! FFMPEG.WASM 是流行的 FFmpeg 多媒体工具链的 WebAssembly 版本,它使用 WebAssembly 线程直接在浏览器中有效地编码视频。还有更多令人兴奋的使用 WebAssembly 线程的示例。请务必查看演示并将您自己的多线程应用程序和库带到网络上!