6.1 多线程支持 本节摘要:线程提案给 Wasm 补上了并行这块最长的短板:共享线性内存加原子指令构成跨线程协作的最小完整件。本节从提案坎坷的批准史讲起,拆解共享内存的语义与同步原语,给出 Rust 侧的并行实战写法,并把死锁与伪共享两颗地雷提前标出。 最初把共享内存带进浏览器的尝试,日子并不好过:SharedArrayBuffer 曾因安全事件被全线禁用,直到跨源隔离响应头成为部署前提后才重新开放——这段历史解释了为什么多线程是 Wasm 提案里"语义最简单、部署最曲折"的一个。如今规范与部署双双就位,并行终于成为可以放心使用的舱位。
本节摘要:线程提案给 Wasm 补上了并行这块最长的短板:共享线性内存加原子指令构成跨线程协作的最小完整件。本节从提案坎坷的批准史讲起,拆解共享内存的语义与同步原语,给出 Rust 侧的并行实战写法,并把死锁与伪共享两颗地雷提前标出。
最初把共享内存带进浏览器的尝试,日子并不好过:SharedArrayBuffer 曾因安全事件被全线禁用,直到跨源隔离响应头成为部署前提后才重新开放——这段历史解释了为什么多线程是 Wasm 提案里"语义最简单、部署最曲折"的一个。如今规范与部署双双就位,并行终于成为可以放心使用的舱位。
Wasm 的线程模型刻意保持薄:规范本身不定义"创建线程"的指令,线程由宿主提供(浏览器的 Worker、服务端的原生线程),规范的职责只有两条——允许多个实例共享同一线性内存,以及提供保证跨线程可见性的原子指令。前者解决"数据在哪",后者解决"何时可见"。
共享的建立方式在第五章已经露过面:JavaScript 创建 SharedArrayBuffer 承载的 Memory,作为导入递给每个 Worker 里的实例,多个实例从此操作同一片字节。每个实例还带一份私有的"不可共享内存"作为栈区,调用栈与局部状态互不干扰——共享是显式选择,不是默认。
原子指令家族覆盖读写与算术:atomics.load、atomics.store 保证单次操作的原子性,atomics.rmw 系列提供加法交换等读改写复合操作,atomics.wait 与 atomics.notify 提供阻塞等待与唤醒。内存序默认为顺序一致——最保守也最不坑人的选择,代价是少量优化空间;对绝大多数应用,"不操心内存序"远比"榨干最后一丝性能"重要。
(module (memory (export "mem") 1 1 shared) ;; 原子地把共享内存偏移零处的计数加一 (func $inc (result i32) (atomic.rmw.add (i32.const 0) (i32.const 1))))
shared 后缀声明这块内存可共享;宿主侧递给多个 Worker 的实例后,每个实例调用 inc 都在对同一处计数做原子累加——无锁计数器的完整实现,五个字节的语义。
Rust 生态把线程提案包装成了近乎无感的开发体验,wasm-bindgen-rayon 把 Rayon 数据并行框架接到浏览器线程池上:
use rayon::prelude::*; use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn blur(image: &mut [u8], width: u32, radius: u32) { // Rayon 的并行迭代器在浏览器里跑在 Worker 线程池上 image .par_chunks_mut((width * 4) as usize) // 每行一个任务 .for_each(|row| blur_row(row, width, radius)); }
要让它真正并行,宿主侧还需两步:用跨源隔离头开启 SharedArrayBuffer(第五章交代过),并在初始化时把线程池规模告诉运行时。值得注意的是核数上限:Worker 数量超过物理核数只会增加调度开销,按 hardwareConcurrency 减一配置是稳妥起点。

死锁的地形在 Wasm 里与原生世界相同,但有一个特有拐点:atomics.wait 在浏览器主线程不可用(主线程不允许阻塞),等待方只能是 Worker;若主线程持有锁又等 Worker 结果,Worker 再等那把锁,僵局即刻成立。防御纪律一句话:锁的持有期绝不跨消息边界——主线程发消息前先释放,Worker 的等待永不依赖主线程的动作。
伪共享更隐蔽:两个线程各自频繁写的变量恰好落在同一缓存行(通常六十四字节),缓存一致性协议来回弹跳,性能悄悄腰斩而逻辑完全正确。检测靠剖析(缓存行弹跳表现为核间流量激增),防御靠布局——热写变量按缓存行对齐隔离,各自留白到六十四字节边界。并行扩展性问题的排查顺序由此定型:先看核数扩展曲线,再查缓存行分布,最后才怀疑算法切分。
数据竞争值得给一个最小样本,因为它错得悄无声息。两个线程对同一地址做"读加写":
线程甲:读计数(得到 5)→ 加一 → 写回 6 线程乙:读计数(得到 5)→ 加一 → 写回 6 // 甲的结果被覆盖
两次累加只落得一次的结果,且九成九的运行里碰不上——测试全绿,上线丢数据。修复只需把"读加写"合并为原子读改写(第六章开头那段 atomic.rmw.add),一次不可分割的操作让中间状态无处藏身。判断自己代码有没有这个隐患的口诀:任何"先读后算再写回"的共享变量序列,都是竞争候选——逐个盘问,合并为原子操作或纳入锁的保护范围。这条纪律连同前两颗地雷,构成并行代码评审的固定三查:查竞争、查死锁、查伪共享。
并行设计的最后一步是把切分策略写下来。同一份数据,按行切还是按块切、边界区域由谁处理、汇总在哪个线程完成——这些决定最好在编码前落成一页纸的并行方案,评审时对照三查清单过一遍。并行 bug 的修复成本随发现时机指数增长,一页纸的方案评审是这条成本曲线上最便宜的拦截点。
并行解决了"多核用不上",下一节解决"逐字节太慢":SIMD 向量指令与性能扩展家族。