导语:单线程 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.load | 0x10 | 原子读 4 字节 |
i32.atomic.store | 0x17 | 原子写 4 字节 |
i32.atomic.rmw.add | 0x1E | 原子加(xadd) |
i32.atomic.rmw.sub | 0x1F | 原子减 |
i32.atomic.rmw.and / or / xor | 0x26/0x27/0x28 | 原子位运算 |
i32.atomic.rmw.xchg | 0x21 | 原子交换(fetch-and-set) |
i32.atomic.rmw.cmpxchg | 0x2B | 原子比较交换(CAS) |
atomic.notify | 0x00 | 唤醒等待线程 |
i32.atomic.wait | 0x01 | 阻塞等待直到被唤醒/超时 |
atomic.fence | 0x03 | 内存栅栏 |
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 内部做的事:
- 创建 N 个 Web Worker(N = 硬件并发数)
- 每个 worker 实例化同一 WASM 模块,共享同一 SharedArrayBuffer
- 把主线程内存的"所有权"交给 worker 池
- 之后
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 |
| Atomics | 0xFE 前缀,顺序一致;CAS 与 wait/notify 构成完整同步工具 |
| Rust | +atomics,+bulk-memory;生产用 wasm-bindgen-rayon |
| 线程池 | 每 worker 共享实例 + 内存分片 + 原子累加 |
实践建议:
- 浏览器必须带 COOP/COEP 头——没有隔离就没有 SharedArrayBuffer,部署层就要配好
- 优先 wasm-bindgen-rayon——手动搭建 worker 池极易踩"栈越界/内存竞争/实例化时序"三类坑
- 锁用 wait/notify 替代自旋——临界区超过微秒级就挂起,别让核空转
- 数据分区再并行——把任务拆成独立分片,用原子计数器汇总,避免共享可变状态
- 排查竞争用确定性重现——
wasm-opt加--enable-threads后,用 Node 的Atomics在宿主侧写单测验证不变式
多线程通常与 SIMD 向量化 配合使用——“先向量化内层循环,再线程化外层分片"是 WASM 性能优化的黄金组合,详见性能优化专题。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。