从 C、C++ 和 Rust 中使用 WebAssembly 线程

了解如何将用其他语言编写的多线程应用程序引入 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.MemoryArrayBuffer 的包装器,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.nowperformance.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 中阻塞辅助线程而不会发生死锁。

第三,如果您正在处理库并且仍然需要阻塞,则可以创建自己的 Worker,导入 Emscripten 生成的代码,并使用 Comlink 将其公开给主线程。主线程将能够将任何导出的方法作为异步函数调用,这样也可以避免阻塞 UI。

在像前面示例这样的简单应用程序中,-s PROXY_TO_PTHREAD 是最佳选择

emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js

C++

所有相同的注意事项和逻辑都以相同的方式应用于 C++。您获得的唯一新功能是访问更高级别的 API,例如 std::threadstd::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-bindgenwasm-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 示例 以获取显示端到端演示

真实世界的用例

我们在 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!