WASM 多线程与 SharedArrayBuffer

详解 WebAssembly Threads 提案:共享内存与 SharedArrayBuffer、Atomics 原子指令、跨源隔离(COOP/COEP)、Rust std::thread 与 worker 线程池、自旋锁与 wait/notify 同步原语。

导语:单线程 WASM 的天花板

WASM 诞生之初是单线程的:一个模块实例一个栈、一份线性内存,指令顺序执行。这对于函数式计算够用,但面对图像/视频处理、机器学习、数据分析这些天生可并行的负载,单线程只能望洋兴叹。

WebAssembly Threads 提案(已进入 W3C 标准)带来了两样东西:共享内存(SharedArrayBuffer 语义)和 Atomics 原子指令。WASM 从此可以真正利用多核——每个 worker 运行一个模块实例,所有实例共享同一块线性内存,通过原子指令协调。

一句话总结:WASM 多线程 = 共享同一线性内存的多个实例 + Atomics 原子指令 + JS worker 作为线程载体,内存是通信通道,原子指令是同步工具。


1. WebAssembly Threads 提案

1.1 共享内存:从 ArrayBuffer 到 SharedArrayBuffer

普通线性内存是 ArrayBuffer(独享,不可跨 worker 共享);多线程需要的是 SharedArrayBuffer——同一块物理内存可被多个线程同时读写:

// 普通内存:只能被当前实例 + 宿主 JS 访问
const mem = new WebAssembly.Memory({ initial: 10 });

// 共享内存:可跨 worker/实例共享
const sharedMem = new WebAssembly.Memory({ initial: 10, maximum: 20, shared: true });
// shared: true 时,maximum 必填;底层是 SharedArrayBuffer
console.log(sharedMem.buffer instanceof SharedArrayBuffer); // true

// 通过 postMessage 把内存"传"给 worker(零拷贝,引用共享)
worker.postMessage({ mem: sharedMem }, [sharedMem.buffer]);

WAT 侧声明共享内存:

(module
  (memory $m 10 20 shared)   ;; 初始 10 页,最大 20 页,共享
  ;; 注意:shared 内存的 memory.grow 会被广播给所有线程
  (export "memory" (memory $m)))

1.2 跨源隔离:COOP / COEP

安全前提:SharedArrayBuffer 只能在**跨源隔离(cross-origin isolated)**的上下文中使用——否则 new WebAssembly.Memory({ shared: true }) 和 SharedArrayBuffer 会直接报错。

两个响应头必须同时生效:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
头作用
COOP: same-origin让窗口与其 opener 隔离,防止侧信道(Spectre)跨进程读取
COEP: require-corp强制所有跨源资源显式声明 CORS/COEP 兼容,否则不加载

验证隔离状态:

// 隔离成功时,crossOriginIsolated === true
if (crossOriginIsolated) {
  console.log("可以安全使用 SharedArrayBuffer + Atomics");
} else {
  console.warn("需要设置 COOP/COEP 头");
}

一句话总结:Threads 提案 = shared memory + atomics;浏览器为了防 Spectre 类侧信道,强制要求 COOP/COEP 双头才能开启共享内存。


2. Atomics 指令

2.1 原子操作(0xFE 前缀)

所有原子指令都用 0xFE 前缀 + 子操作码编码(在二进制格式专题里提到过)。原子操作保证**读改写(RMW)**的整体性:两个线程同时 add 同一地址,结果是线性化的,不会丢失任一更新。

常用原子指令速查:

WAT 指令二进制(0xFE +)语义
i32.atomic.load0x10原子读 4 字节
i32.atomic.store0x17原子写 4 字节
i32.atomic.rmw.add0x1E原子加(xadd)
i32.atomic.rmw.sub0x1F原子减
i32.atomic.rmw.and / or / xor0x26/0x27/0x28原子位运算
i32.atomic.rmw.xchg0x21原子交换(fetch-and-set)
i32.atomic.rmw.cmpxchg0x2B原子比较交换(CAS)
atomic.notify0x00唤醒等待线程
i32.atomic.wait0x01阻塞等待直到被唤醒/超时
atomic.fence0x03内存栅栏

WAT 示例:

;; 对 addr 做一次原子的 +1(计数器的标准写法)
(func $atomic_inc (param $addr i32)
  (local $expected i32)
  (loop $retry
    local.get $addr
    local.get $expected
    i32.atomic.rmw.cmpxchg     ;; CAS:若 [addr]==expected 则写 expected+1
    local.get $expected
    i32.add
    local.set $expected
    local.get $addr
    i32.load
    local.get $expected
    i32.ne
    br_if $retry))

2.2 内存栅栏与顺序一致性

atomic.fence 保证内存访问顺序:其之前的读写对其它线程可见,其之后的读写不会被重排到前面。

内存序保证
普通 load/store无跨线程保证,可被重排
atomic.load/atomic.store顺序一致(sequentially consistent),全局总序
atomic.fence建立 happens-before 边界

WASM 的 Atomics 默认就是顺序一致——不提供 acquire/release 弱序(对比 C++ 的 memory_order_acquire),这让编程更安全但略慢。若需要弱序,靠编译器(LLVM 的 atomics)在 IR 层面优化。

一句话总结:Atomics 用 0xFE 前缀指令提供顺序一致的原子读、写、CAS 与 wait/notify;atomic.fence 显式建立内存序边界。


3. Rust 多线程实现

3.1 编译设置:atomics + bulk-memory

Rust 侧开启线程需要两个 target feature:

# .cargo/config.toml(wasm32-unknown-unknown)
[target.wasm32-unknown-unknown]
rustflags = [
  "-C", "target-feature=+atomics,+bulk-memory,+mutable-globals",
  # 线程库需要 bulk-memory(memcpy/memset 的 memory.copy/fill)
]

构建命令(注意:带 atomics 时不能 -C link-arg=--shared-memory 缺失):

RUSTFLAGS="-C target-feature=+atomics,+bulk-memory" \
cargo build --target wasm32-unknown-unknown --release
# 产物会 import env.memory(shared),且带 '__wbindgen_thread_xform__' 等符号

3.2 直接使用 std::thread

std::thread 在 wasm 上是可用的——前提是运行时提供了 worker 并把模块实例化为多个线程:

use std::thread;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};

#[no_mangle]
pub fn parallel_sum(data: *const u32, len: usize, nthreads: usize) -> u32 {
    let data = unsafe { std::slice::from_raw_parts(data, len) };
    let counter = Arc::new(AtomicU32::new(0));

    let handles: Vec<_> = (0..nthreads)
        .map(|t| {
            let data = data.to_vec(); // 跨线程闭包需要 'static
            let counter = counter.clone();
            thread::spawn(move || {
                let chunk = len / nthreads;
                let start = t * chunk;
                let end = if t == nthreads - 1 { len } else { start + chunk };
                let sum: u32 = data[start..end].iter().sum();
                counter.fetch_add(sum, Ordering::SeqCst);
            })
        })
        .collect();

    for h in handles { h.join().unwrap(); }
    counter.load(Ordering::SeqCst)
}

但直接用 std::thread 需要你自己管理 JS worker 池和实例化。生产上推荐现成的:

3.3 wasm-bindgen-rayon:一行开启线程池

wasm-bindgen-rayon 把线程池、worker 分发、内存共享全部封装好:

[dependencies]
wasm-bindgen-rayon = "1.0"
rayon = "1.8"
use wasm_bindgen_rayon::init_thread_pool;
use rayon::prelude::*;

#[wasm_bindgen]
pub async fn run(n: usize) -> usize {
    // 只需一步初始化:自动创建 worker、共享内存、启动线程池
    init_thread_pool().await;

    (0..n).into_par_iter().map(|i| i * i).sum()
}
import init, { run } from "./pkg/hello.js";
await init();            // 加载主模块
const result = await run(10_000_000);
console.log(result);

init_thread_pool 内部做的事:

  1. 创建 N 个 Web Worker(N = 硬件并发数)
  2. 每个 worker 实例化同一 WASM 模块,共享同一 SharedArrayBuffer
  3. 把主线程内存的"所有权"交给 worker 池
  4. 之后 rayon 的并行迭代自动跨线程调度

一句话总结:Rust 编译需要 +atomics,+bulk-memory;生产项目直接上 wasm-bindgen-rayon,线程池 + worker 分发一行搞定,内部替你完成共享内存与实例化。


4. 锁与同步原语

4.1 自旋锁(基于 CAS)

不依赖操作系统、纯用户态的自旋锁:

// 基于 i32.atomic.rmw.cmpxchg 的自旋锁
pub struct SpinLock {
    flag: AtomicBool, // 底层是 shared memory 中的 0/1
}

impl SpinLock {
    pub fn lock(&self) {
        while self.flag.compare_exchange(
            false, true, Ordering::SeqCst, Ordering::SeqCst
        ).is_err() {
            // 忙等:用 std::hint::spin_loop() 触发 pause 指令
            std::hint::spin_loop();
        }
    }

    pub fn unlock(&self) {
        self.flag.store(false, Ordering::SeqCst);
    }
}

对应的 WAT(核心 CAS 循环):

(func $spin_lock (param $addr i32)
  (local $ok i32)
  (block $done
    (loop $wait
      (local.set $ok
        (i32.atomic.rmw.cmpxchg $addr (i32.const 0) (i32.const 1)))
      (if (i32.eqz (local.get $ok))
        (br $done))      ;; 拿到锁
      (br $wait))))      ;; 继续自旋

自旋锁的代价:空转烧 CPU。多核场景短临界区可以,长临界区要用 wait/notify 挂起。

4.2 futex 式同步:atomic.wait / notify

WASM 提供了类 futex 原语——i32.atomic.wait 让线程阻塞挂起直到被 atomic.notify 唤醒:

;; 线程 A:等待 addr 的值从 0 变为非 0(超时 1 秒)
(i32.atomic.wait $addr (i32.const 0) (i64.const 1_000_000_000))
;; 返回:0=被唤醒,1=值不匹配(立即返回),2=超时

;; 线程 B:把 addr 写为 1 并唤醒等待者
(i32.store $addr (i32.const 1))
(atomic.notify $addr (i32.const 1))   ;; 唤醒 1 个等待者

Rust 侧封装(条件变量风格的等待):

use std::sync::atomic::{AtomicU32, Ordering};

// 消费者等待:值变化则唤醒
let data_ready = AtomicU32::new(0);

// 生产者
data_ready.store(1, Ordering::SeqCst);
// notify 映射到 wasm 的 atomic.notify(std 内部已实现)

// 消费者
while data_ready.load(Ordering::SeqCst) == 0 {
    // 内部调用 i32.atomic.wait(阻塞,不烧 CPU)
    std::thread::park();
}
原语适用场景代价
自旋锁(CAS)短临界区、无系统调用忙等烧 CPU
atomic.wait/notify长等待、生产者-消费者需要运行时支持(浏览器/JS worker 内有效)
无锁(fetch_add/xchg)计数器、发布订阅最轻量,仅限特定结构

一句话总结:同步两级——短临界区用 CAS 自旋锁,长等待用 atomic.wait/notify(类 futex)挂起线程;优先选无锁结构(原子计数器)避免锁开销。


5. worker 线程池

5.1 JS 侧手动线程池

不依赖 wasm-bindgen-rayon 时,你需要自己搭建 worker 池:

// main.js —— 线程池调度器
const workers = [];
const poolSize = navigator.hardwareConcurrency || 4;

const sharedMemory = new WebAssembly.Memory({
  initial: 100, maximum: 500, shared: true,
});

const moduleBytes = await fetch("app.wasm").then(r => r.arrayBuffer());
const { module, instance } = await WebAssembly.instantiate(moduleBytes, {
  env: { memory: sharedMemory },
});

// 每个 worker 共享同一份模块 + 内存
for (let i = 0; i < poolSize; i++) {
  const w = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
  w.postMessage({ module, memory: sharedMemory }, [moduleBytes, sharedMemory.buffer]);
  workers.push(w);
}
// worker.js —— 每个线程一个 WASM 实例
let instance;

self.onmessage = async ({ data }) => {
  if (!instance) {
    // 实例化:共享模块 + 共享内存 → 每个 worker 得到独立栈、共享堆
    instance = await WebAssembly.instantiate(data.module, {
      env: { memory: data.memory },
    });
  }
  // 分配任务区间,直接读写共享内存
  const start = data.start, end = data.end;
  const result = instance.exports.work(start, end);
  self.postMessage({ result });
};

5.2 内存分区与数据通信

多线程编程的核心模型——数据放进共享内存,线程通过偏移量协作:

┌──────────────── 共享线性内存(SharedArrayBuffer)────────────────┐
│  [0] 任务元数据:待处理项数、游标(原子计数器)                     │
│  [4] 结果累加器(原子,fetch_add)                                │
│  [8] 数据区:分片给 N 个 worker,每片 [8+chunk*t, 8+chunk*(t+1))  │
│  ...                                                            │
│  高地址:每线程私有栈(互不重叠)                                    │
└───────────────────────────────────────────────────────────────┘

Worker 0 ──▶ 处理分片 0(偏移 8+0)
Worker 1 ──▶ 处理分片 1(偏移 8+chunk)
Worker 2 ──▶ 处理分片 2(偏移 8+2*chunk)
// worker 侧:每个线程把自己的结果原子累加进共享内存
#[no_mangle]
pub extern "C" fn work(start: *mut u32, len: usize, counter: *mut AtomicU32) {
    let data = unsafe { std::slice::from_raw_parts(start, len) };
    let sum: u32 = data.iter().sum();
    unsafe {
        // fetch_add:多线程并发累加不丢失
        (*counter).fetch_add(sum, Ordering::SeqCst);
    }
}

关键约束:

  • 栈不能共享——每个实例的调用栈是独立的,互不侵犯
  • 数据竞争靠 Atomics 消除——同一地址的并发读写在原子指令层面线性化
  • 初始化同步——worker 实例化完成后,主线程用 atomic.notify 广播"开工"

一句话总结:线程池 = 每 worker 一个共享同一内存的实例 + 内存偏移分区 + 原子计数器协调;主线程只做任务切分与结果汇总,数据零拷贝流动。


6. 总结与实践建议

主题核心结论
Threads 提案shared memory(SharedArrayBuffer)+ Atomics,实例共享内存、独立栈
浏览器前提COOP/COEP 双头 → crossOriginIsolated
Atomics0xFE 前缀,顺序一致;CAS 与 wait/notify 构成完整同步工具
Rust+atomics,+bulk-memory;生产用 wasm-bindgen-rayon
线程池每 worker 共享实例 + 内存分片 + 原子累加

实践建议:

  1. 浏览器必须带 COOP/COEP 头——没有隔离就没有 SharedArrayBuffer,部署层就要配好
  2. 优先 wasm-bindgen-rayon——手动搭建 worker 池极易踩"栈越界/内存竞争/实例化时序"三类坑
  3. 锁用 wait/notify 替代自旋——临界区超过微秒级就挂起,别让核空转
  4. 数据分区再并行——把任务拆成独立分片,用原子计数器汇总,避免共享可变状态
  5. 排查竞争用确定性重现——wasm-opt 加 --enable-threads 后,用 Node 的 Atomics 在宿主侧写单测验证不变式

多线程通常与 SIMD 向量化 配合使用——“先向量化内层循环,再线程化外层分片"是 WASM 性能优化的黄金组合,详见性能优化专题。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「wasm」更多文章

  1. WASM 调试与性能剖析:源码映射、断点调试与火焰图分析
  2. WASM 游戏与 WebGPU:高性能浏览器图形渲染与游戏引擎
  3. WASM 智能合约:区块链执行环境、确定性运行与合约开发