如何利用 SharedArrayBuffer 配合 Atomics 构建极致性能的跨线程协作模型

想用 SharedArrayBuffer 和 Atomics 搭建一个高性能的跨线程协作模型?这个想法很好,但现实很骨感。除非你能同时满足三个硬性前提——跨域隔离、正确同步、内存布局可控——否则,所谓的“极致性能”不仅无从谈起,反而会招来静默的数据错乱,或者干脆给你一个冷冰冰的 TypeError: SharedArrayBuffer is not defined。
SharedArrayBuffer 创建失败的常见报错和对应检查点
当浏览器控制台抛出 SharedArrayBuffer is not defined 或 Atomics is not defined 时,别急着怀疑自己的代码。这通常是环境配置没达标发出的信号。你需要按顺序检查以下几个关键点:
- 服务器响应头必须成对出现:
Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Embedder-Policy: require-corp,缺一不可。 - 所有跨源资源标签需显式声明:包括
、、等,哪怕资源来自同域,也必须加上crossorigin属性。 - 开发环境有讲究:直接双击打开本地 HTML 文件(
file://协议)是行不通的。必须通过本地服务器(如localhost或127.0.0.1)访问,用npx serve -p 8080这类工具快速启动一个服务是常见做法。 - 浏览器版本是硬门槛:完整支持需要 Chrome / Edge 105+、Firefox 93+ 或 Safari 16.4+。尤其要注意,旧版 Safari 对
Atomics.wait()的支持并不完整。
Atomics.wait() 不是 setTimeout,用错就卡死
很多人把 Atomics.wait() 误解为“让线程睡一会儿”的定时器,这可是个危险的误会。它的核心逻辑其实很单纯:当指定 Int32Array 索引位置的值**恰好等于**你预期的那个值时,它才会挂起当前线程。之后,这个线程会一直沉睡,直到有另一个线程调用 Atomics.notify() 来唤醒它。它不接受毫秒参数,也不保证何时会被唤醒。
- 典型的错误用法:
Atomics.wait(view, 0, view[0], 1000)。这里的第四个参数(超时时间)在多数浏览器中会被直接忽略。更关键的是,如果调用时view[0]的值已经变了,函数会立刻返回"not-equal",根本不会进入等待状态。 - 正确的协作模式:通常由主线程先写入一个状态值(如
view[0] = 1),然后 Worker 线程检查该值(Atomics.load(view, 0) === 1)并调用Atomics.wait(view, 0, 1)进入等待。最后,由主线程在适当时机调用Atomics.notify(view, 0, 1)来唤醒 Worker。 - 最重要的一条原则:永远不要在没有配套
notify机制的场景下孤零零地使用wait。否则,线程将永久挂起,调试起来会异常棘手。
多线程计数器看似简单,但非原子操作必出错
来看一个经典场景:用4个Worker线程并发执行10000次递增操作。下面两段代码,你觉得哪段能稳定得到40000这个结果?
// ❌ 错误:非原子读-改-写 int32View[0] = int32View[0] + 1; // ✅ 正确:单条原子指令完成 Atomics.add(int32View, 0, 1);
- 错误代码的问题:
int32View[0] = int32View[0] + 1这条语句看似一气呵成,实则被拆解为“读取 → 计算 → 写入”三个独立的步骤。在多线程环境下,其他线程完全可能在这个间隙插入并修改数据,导致更新丢失(lost update),最终结果远小于预期。 - 原子操作的威力:
Atomics.add()是直接映射到CPU级别的原子指令,它的“读-改-写”操作是不可分割的。类似的,Atomics.compareExchange()是实现自旋锁(spinlock)的理想选择。 - 即使是“只读”也要注意:为了保证能读取到其他线程最新写入的值,避免CPU缓存不一致带来的问题,读取共享内存时也应优先使用
Atomics.load(view, i),而不是直接访问view[i]。
WASM 多线程中 SharedArrayBuffer 的传递方式差异
在 WebAssembly 的多线程场景中使用 SharedArrayBuffer,其路径和纯 Ja vaScript Worker 有所不同,容易混淆。
- 编译标志是前提:使用 Emscripten 编译时,必须加上
-pthread -s USE_PTHREADS=1参数,否则生成的 WASM 模块根本无法识别共享内存。 - 内存声明需特殊标识:WASM 的线性内存必须在声明时带上
shared标识,例如(memory (shared 1 10)),表示初始1页(64KB),最大可扩展到10页。 - 传递的是指针,而非对象:JS 主线程传递给 WASM Worker 的,并不是
SharedArrayBuffer对象本身,而是通过Module._malloc()等函数分配的指针地址。WASM 运行时会自行将这个地址映射到共享内存段。 - 原子操作在WASM内部完成:WASM 内部会调用
__atomic_add_fetch等内置函数,这些函数最终会被编译为对 Ja vaScript 层Atomics.add的调用。开发者通常无需手动编写 JS 层的原子操作代码。
最后,必须警惕一个最容易被忽略的陷阱:共享内存没有自动垃圾回收。一旦创建了 SharedArrayBuffer,它就会一直驻留在内存中,直到所有引用(包括所有 Worker 线程中的 TypedArray 视图)都被显式释放。如果 Worker 没有正确调用 terminate(),或者视图引用没有置空,就会导致内存泄漏,而且这种泄漏很难被常规的开发者工具检测到。
