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

了解如何将使用其他语言编写的多线程应用引入 WebAssembly。

Ingvar Stepanyan
Ingvar Stepanyan

WebAssembly 线程支持是 WebAssembly 中最重要的性能增强功能之一。借助它,您可以并行在单独的核心上运行代码的部分,也可以对输入数据的独立部分运行相同的代码,将其扩展到用户拥有的核心数量,并显著缩短总体执行时间。

在本文中,您将学习如何使用 WebAssembly 线程将使用 C、C++ 和 Rust 等语言编写的多线程应用引入到 Web 中。

WebAssembly 线程不是一项单独的功能,而是多种组件的组合,可让 WebAssembly 应用在 Web 上使用传统的多线程范式。

网络工作器

第一个组件是您熟悉且喜爱的 JavaScript 常规 Worker。WebAssembly 线程使用 new Worker 构造函数创建新的底层线程。每个线程都会加载 JavaScript 粘合代码,然后主线程使用 Worker#postMessage 方法与其他线程共享已编译的 WebAssembly.Module 以及共享的 WebAssembly.Memory(见下文)。这会建立通信,并允许所有这些线程在同一共享内存上运行相同的 WebAssembly 代码,而无需再次通过 JavaScript。

Web Worker 已经存在十多年了,得到了广泛的支持,并且不需要任何特殊标志。

SharedArrayBuffer

WebAssembly 内存由 JavaScript API 中的 WebAssembly.Memory 对象表示。默认情况下,WebAssembly.MemoryArrayBuffer 的封装容器,后者是一种只能由单个线程访问的原始字节缓冲区。

> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer {  }

为了支持多线程,WebAssembly.Memory 还获得了共享变体。当通过 JavaScript API 使用 shared 标志创建时,或由 WebAssembly 二进制文件本身创建时,它会变为 SharedArrayBuffer 的封装容器。它是 ArrayBuffer 的变体,可以与其他线程共享,并且可以从任一端同时读取或修改。

> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer {  }

与通常用于主线程和 Web Worker 之间通信的 postMessage 不同,SharedArrayBuffer 无需复制数据,甚至无需等待事件循环发送和接收消息。相反,所有线程几乎都会立即看到任何更改,这使得它成为传统同步基元更理想的编译目标。

SharedArrayBuffer有着复杂的历史。它最初于 2017 年年中在多款浏览器中发布,但由于发现了 Spectre 漏洞,因此在 2018 年初不得不停用。具体原因在于,Spectre 中的数据提取依赖于时间攻击,即测量特定代码段的执行时间。为了使此类攻击更难以成功,浏览器降低了 Date.nowperformance.now 等标准时间 API 的精度。不过,共享内存与在单独线程中运行的简单计数器循环相结合也是获取高精度时间戳的非常可靠的方式,而且如果不大幅限制运行时性能,则更难以缓解。

不过,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 允许每个线程读取和写入相同的内存,但为了实现正确的通信,您需要确保它们不会同时执行冲突的操作。例如,一个线程可能会开始从共享地址读取数据,而另一个线程正在向该地址写入数据,因此第一个线程现在会获得损坏的结果。此类 bug 称为竞态条件。为了防止出现争用情况,您需要以某种方式同步这些访问。这时,原子操作就派上用场了。

WebAssembly 原子操作是 WebAssembly 指令集的扩展,可让您“原子地”读取和写入小数据单元(通常是 32 位和 64 位整数)。也就是说,以一种方式保证不会有两个线程同时读取或写入同一单元格,从而在低级别防止此类冲突。此外,WebAssembly 原子操作还包含另外两种指令类型:“等待”和“通知”,它们允许一个线程在共享内存中的给定地址上进入休眠状态(“等待”),直到另一个线程通过“通知”唤醒它。

所有更高级别的同步基元(包括通道、互斥量和读写锁)都基于这些指令构建而成。

如何使用 WebAssembly 线程

功能检测

WebAssembly 原子操作和 SharedArrayBuffer 是相对较新的功能,尚未在所有支持 WebAssembly 的浏览器中提供。如需了解哪些浏览器支持新的 WebAssembly 功能,请参阅 webassembly.org 路线图

为确保所有用户都能加载您的应用,您需要构建两个不同的 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 兼容的实现,可在 Web Worker、共享内存和原子操作的基础上构建 pthread 库,以便相同的代码无需更改即可在 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…]

发生了什么情况?问题是,网络上大多数耗时的 API 都是异步的,并且依赖于事件循环来执行。与传统环境相比,这一限制是一个重要的区别。在传统环境中,应用通常以同步阻塞方式运行 I/O。如需了解详情,请参阅有关使用 WebAssembly 中的异步 Web API 的博文。

在本例中,代码会同步调用 pthread_create 以创建后台线程,然后再对 pthread_join 进行另一个同步调用,以等待后台线程完成执行。不过,在使用 Emscripten 编译此代码时,后台使用的 Web Worker 是异步的。因此,情况是,pthread_create 只会在下一次事件循环运行时安排新的工作器线程,但 pthread_join 会立即阻止事件循环等待该工作器,这样做会阻止创建该工作器。这是死锁的一个典型示例。

解决此问题的一种方法是在程序启动之前预先创建一个工作器池。调用 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 时,它会阻塞界面线程 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 选项,该选项除了应用本身创建的任何嵌套线程之外,还会将主应用代码分流到单独的线程中。这样,主代码就可以随时安全地阻塞,而不会冻结界面。顺便提一下,使用此选项时,您也不必预创建线程池,而是可以让 Emscripten 利用主线程创建新的底层 Worker,然后在 pthread_join 中阻塞辅助线程,而不会发生死锁。

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

在简单的应用(例如前面的示例)中,-s PROXY_TO_PTHREAD 是最佳选项:

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

C++

所有相同的注意事项和逻辑都同样适用于 C++。您唯一获得的新特性是可以访问 std::threadstd::async 等更高级别的 API,这些 API 会在后台使用前面讨论的 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 函数。此函数将创建一个工作器池,并在程序的整个生命周期内重复使用这些工作器来执行 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 地球是另一项值得注意的服务,该服务的 Web 版本使用 WebAssembly 线程。

FFMPEG.WASM 是热门 FFmpeg 多媒体工具链的 WebAssembly 版本,它使用 WebAssembly 线程直接在浏览器中对视频进行高效编码。

您还可以找到许多其他使用 WebAssembly 线程的令人兴奋的示例。请务必查看演示,并将您自己的多线程应用和库引入到 Web 中!