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

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

Ingvar Stepanyan
Ingvar Stepanyan

WebAssembly 线程支持是 WebAssembly 最重要的性能提升之一。它允许您在单独的核心上并行运行部分代码,或者针对输入数据的独立部分并行运行相同的代码,可将其扩容到尽可能多的核心,同时显著缩短总执行时间。

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

WebAssembly 线程的工作原理

WebAssembly 线程不是独立的功能,而是多个组件的组合,使 WebAssembly 应用能够在网络上使用传统的多线程模式。

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(一种只能由单个线程访问的原始字节缓冲区)的封装容器。

> 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 atomics 是对 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 提供了在 Web Worker、共享内存和原子之上构建的 pthread 库的API 兼容实现,因此相同的代码无需更改即可在网页上运行。

下面我们来看一个示例:

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 库的头文件通过 pthread.h 包含在内。您还可以查看几个用于处理线程的重要函数。

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 仅会安排在下一次事件循环运行时创建要创建新的工作器线程,但 pthread_join 会立即阻止事件循环以等待该工作器,如此一来阻止系统创建它。这是一个典型的死锁例子。

解决此问题的一种方法是在程序启动之前提前创建工作器池。调用 pthread_create 时,它可以从池中获取可供使用的 worker,在其后台线程上运行提供的回调,并将 worker 返回池。所有这些操作都可以同步完成,因此只要池足够大,就不会出现死锁。

这正是 Emscripten 使用 -s PTHREAD_POOL_SIZE=... 选项允许的作用。它允许指定线程数(固定数量或者 navigator.hardwareConcurrency 等 JavaScript 表达式),以创建与 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++。您获得的唯一新情况是能够访问更高级别的 API,如 std::threadstd::async,这些 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 旨在用于网络环境,则与 JavaScript API 的任何交互都留给外部库和工具(例如 wasm-bindgenwasm-pack)。遗憾的是,这意味着标准库无法识别 Web Worker,并且 std::thread 等标准 API 在编译为 WebAssembly 时将不起作用。

幸运的是,生态系统的大部分都依赖于较高级别的库来完成多线程处理。在此级别上,抽象化所有平台差异要容易得多。

特别是,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。如需使用该库,您需要将其添加为依赖项,然后按照docs中所述的配置步骤操作。上例最终应如下所示:

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++) 等格式。由于单纯通过 SIM 卡,我们通过将 WebAssembly 线程与 WebAssembly 线程合并,结果实现了一致的 1.5 倍至 3 倍加速效果,还实现了 1.5 倍至 3 倍的加速度,而 WebAssembly 的提升速度实现了一致的提升。

Google 地球是另一项著名服务,其网页版使用了 WebAssembly 线程。

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

还有很多使用 WebAssembly 线程的更多有趣示例。请务必查看演示,并将您自己的多线程应用和库引入 Web!