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