SharedArrayBuffer 与 Atomics:多线程 JS 的内存模型
Web Worker 让 JavaScript 拥有了真正的多线程,SharedArrayBuffer 让多个线程共享同一块内存——但共享内存带来了 CPU 级别的并发问题:乱序执行、缓存不一致、数据竞争。Atomics 是解决这些问题的低级工具。
🔹 Level 1 · 你需要知道的
Web Worker:线程但不共享内存
// 主线程
const worker = new Worker('worker.js');
worker.postMessage({ data: [1, 2, 3, 4, 5] }); // 复制数据(结构化克隆)
worker.onmessage = e => console.log('result:', e.data);
postMessage 默认使用结构化克隆(Structured Clone):数据被深拷贝,主线程和 Worker 各自持有独立副本。对大数据(如视频帧、科学计算数组)来说,每次通信都要复制,性能开销显著。
SharedArrayBuffer:真正的共享内存
// 主线程
const sab = new SharedArrayBuffer(4 * Int32Array.BYTES_PER_ELEMENT); // 16字节
const view = new Int32Array(sab);
view[0] = 42;
const worker = new Worker('worker.js');
worker.postMessage(sab); // 传递的是引用,不是副本!
// worker.js
self.onmessage = e => {
const view = new Int32Array(e.data); // 与主线程共享同一内存
console.log(view[0]); // 42(同一块内存)
view[0] = 100; // 主线程也能看到这个修改
};
| 特性 | postMessage(普通值) | postMessage(SharedArrayBuffer) |
|---|---|---|
| 数据传递方式 | 结构化克隆(深拷贝) | 传递引用(共享) |
| 内存占用 | 两份 | 一份 |
| 通信开销 | 与数据大小成正比 | O(1)(只传引用) |
| 数据一致性 | 各自独立 | 共享,需要同步 |
| 安全限制 | 无特殊要求 | 需要 COOP + COEP 响应头 |
为什么需要 Atomics
共享内存不能直接读写——即使最简单的 i++ 在多线程下也不安全:
i++ 在汇编层面的3个步骤:
1. LOAD r0, [addr] // 从内存读取 i 的值到寄存器
2. ADD r0, r0, 1 // 寄存器中加1
3. STORE [addr], r0 // 将结果写回内存
两个线程同时执行 i++ (初始值 i=0):
线程A: LOAD r0=0 线程B: LOAD r1=0
线程A: ADD r0=1 线程B: ADD r1=1
线程A: STORE i=1 线程B: STORE i=1
结果:i=1(应该是2!丢失了一次更新)
Atomics 确保这3个步骤作为原子操作执行,不可分割。
使用前提:COOP + COEP 响应头
SharedArrayBuffer 在2018年被暂时禁用(Spectre 攻击),2020年在满足以下条件时恢复:
# 服务器响应头(必须同时设置)
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
设置后,window.crossOriginIsolated 为 true,可以使用 SharedArrayBuffer。
🔸 Level 2 · 它是怎么运行的
为什么共享内存不安全:CPU 级别的问题
现代 CPU 为了性能做了大量优化,这些优化在单线程下完全透明,但在多线程共享内存时造成问题:
问题1:CPU 乱序执行(Out-of-Order Execution)
// 你写的代码:
store(flag, 1); // 步骤1:设置标志
store(data, 42); // 步骤2:写入数据
// CPU 实际执行的顺序(可能):
store(data, 42); // 步骤2 先执行(因为流水线优化)
store(flag, 1); // 步骤1 后执行
// 另一个线程看到 flag=1 时,data 可能还没写入!
问题2:CPU 缓存(Cache Coherency)
CPU 0(线程A) CPU 1(线程B)
L1 Cache: data=0 L1 Cache: data=0(旧值)
写入 data=42 → L1 读取 data(还在 L1 缓存,是0!)
等待缓存一致性协议
(MESI协议需要时间)
不同 CPU 核心有各自的缓存,写入可能停留在 L1/L2 缓存中,其他核心读到的是旧值。
问题3:编译器重排
编译器(包括 JIT)在不改变单线程语义的前提下,可以自由重排指令以优化性能:
// 原始代码
let x = 0, y = 0;
function threadA() { x = 1; r1 = y; }
function threadB() { y = 1; r2 = x; }
// 编译器重排后(合法,因为单线程语义相同):
function threadA() { r1 = y; x = 1; } // 重排!
function threadB() { r2 = x; y = 1; } // 重排!
// 结果:r1=0, r2=0 都可能出现(看似不可能,但多线程下会发生)
Atomics 的操作语义
Atomics 对象提供了一组保证原子性和内存顺序的操作:
┌─────────────────────────────────────────────────────────┐
│ Atomics 操作清单(操作 Int32Array 或 BigInt64Array) │
│ │
│ 读/写操作: │
│ Atomics.load(ta, index) — 原子读取 │
│ Atomics.store(ta, index, value) — 原子写入 │
│ │
│ 算术操作(返回操作前的值): │
│ Atomics.add(ta, index, value) — 原子加 │
│ Atomics.sub(ta, index, value) — 原子减 │
│ Atomics.and(ta, index, value) — 原子位与 │
│ Atomics.or(ta, index, value) — 原子位或 │
│ Atomics.xor(ta, index, value) — 原子位异或 │
│ │
│ CAS 操作: │
│ Atomics.compareExchange(ta, index, expected, replacement)│
│ — 若当前值 === expected,替换为 replacement; │
│ 返回操作前的值 │
│ Atomics.exchange(ta, index, value) │
│ — 无条件替换,返回操作前的值 │
│ │
│ 等待/通知(仅在 Int32Array 和 BigInt64Array 上): │
│ Atomics.wait(ta, index, value, timeout?) │
│ — 若 ta[index] === value,阻塞等待(只能在 Worker 中)│
│ Atomics.notify(ta, index, count?) │
│ — 唤醒等待的 Worker(主线程可以调用) │
│ Atomics.waitAsync(ta, index, value, timeout?) │
│ — 非阻塞等待,返回 Promise(可在主线程调用,ES2024) │
└─────────────────────────────────────────────────────────┘
用 SharedArrayBuffer + Atomics 实现互斥锁
// 互斥锁实现(Mutex)
// 使用 Int32Array 的一个槽位:0=未锁定,1=已锁定
class Mutex {
constructor(sharedBuffer, byteOffset = 0) {
this._sa = new Int32Array(sharedBuffer, byteOffset, 1);
}
lock() {
// 自旋锁:不断尝试 CAS,直到成功将 0 换为 1
while (true) {
// compareExchange(array, index, expectedValue, replacementValue)
// 若 _sa[0] === 0(未锁定),设置为 1(锁定),返回 0(成功)
// 若 _sa[0] !== 0(已锁定),不改变,返回当前值(失败)
const wasUnlocked = Atomics.compareExchange(this._sa, 0, 0, 1);
if (wasUnlocked === 0) {
return; // 成功获取锁
}
// 失败:等待锁被释放再重试
Atomics.wait(this._sa, 0, 1); // 等待直到 _sa[0] !== 1
}
}
unlock() {
Atomics.store(this._sa, 0, 0); // 释放锁(写入0)
Atomics.notify(this._sa, 0, 1); // 唤醒一个等待的 Worker
}
}
// 在 Worker 中使用:
const sab = new SharedArrayBuffer(4);
const mutex = new Mutex(sab);
mutex.lock();
try {
// 临界区:同一时间只有一个 Worker 能执行这里
sharedData[0] += 1;
} finally {
mutex.unlock();
}
happens-before 关系与内存模型
JavaScript 内存模型(基于 ECMAScript 规范第29章)定义了"什么时候一个写入对另一个读取可见":
happens-before 规则(简化版):
1. Program order:同一 Agent 中,代码行按顺序有 happens-before 关系
write(x, 1) happens-before read(x)(同一线程)
2. Synchronizes-with(同步关系):
Atomics.store(ta, i, v) synchronizes-with Atomics.load(ta, i) 读到 v 的时候
Atomics.notify() synchronizes-with 对应的 Atomics.wait() 返回时
3. happens-before 的传递性:
A happens-before B, B happens-before C → A happens-before C
实际含义: 如果线程 A 通过 Atomics.store 写入,线程 B 通过 Atomics.load 读到这个值,那么 A 的 store 之前的所有写入都对 B 的 load 之后的代码可见。这是一个"内存屏障"(Memory Barrier)。
生产者-消费者模式实现
// 共享内存布局:
// [0]: 状态(0=空,1=生产者写入中,2=数据就绪)
// [1]: 数据值
// 生产者(Worker A):
function produce(sab, value) {
const control = new Int32Array(sab, 0, 1); // 状态槽
const data = new Int32Array(sab, 4, 1); // 数据槽
// 等待状态为0(空)
while (Atomics.load(control, 0) !== 0) {
Atomics.wait(control, 0, 1); // 等到不是1(写入中)
Atomics.wait(control, 0, 2); // 等到不是2(就绪)
}
// 写入数据
Atomics.store(control, 0, 1); // 标记写入中
Atomics.store(data, 0, value);
Atomics.store(control, 0, 2); // 标记就绪
Atomics.notify(control, 0); // 通知消费者
}
// 消费者(Worker B):
function consume(sab) {
const control = new Int32Array(sab, 0, 1);
const data = new Int32Array(sab, 4, 1);
// 等待数据就绪
Atomics.wait(control, 0, 0); // 等到不是0(空)
while (Atomics.load(control, 0) !== 2) {
Atomics.wait(control, 0, 1); // 等到不是1(写入中)
}
// 读取数据
const value = Atomics.load(data, 0);
Atomics.store(control, 0, 0); // 重置为空
Atomics.notify(control, 0); // 通知生产者
return value;
}
🔺 Level 3 · 规范怎么定义的
ECMAScript 规范:Agent 与 AgentCluster
规范 §9.7 定义了 Agent(代理)的概念:
An agent comprises a set of ECMAScript execution contexts, an execution context stack, a running execution context, an Agent Record, and an executing thread.
每个 JavaScript 线程(主线程、每个 Web Worker)都是一个独立的 Agent。
An agent cluster is a maximal set of agents that can communicate by operating on shared memory.
主线程和所有通过 new SharedArrayBuffer 共享内存的 Worker 属于同一个 AgentCluster。同一 AgentCluster 中的 Agents 可以通过 SharedArrayBuffer 通信;不同 AgentCluster 之间完全隔离。
Agent 的 [[CanBlock]] 属性:
- 主线程(Browser 主线程):
[[CanBlock]] = false(不能调用Atomics.wait,会阻塞 UI) - Dedicated Worker:
[[CanBlock]] = true - Shared Worker:
[[CanBlock]] = false - Service Worker:
[[CanBlock]] = false
规范 §29.4:Atomics Object
规范定义了 Atomics.compareExchange 的语义(§29.4.4):
Atomics.compareExchange ( typedArray, index, expectedValue, replacementValue )
- Let buffer be ? ValidateIntegerTypedArray(typedArray).
- Let i be ? ValidateAtomicAccess(typedArray, index).
- Let expected be ? ToInteger(expectedValue).
- Let replacement be ? ToInteger(replacementValue).
- Let elementSize be the Element Size value specified in Table 68 for typedArray.[[TypedArrayName]].
- Let offset be typedArray.[[ByteOffset]].
- Let byteIndex be (i × elementSize) + offset.
- Let v be ? GetValueFromBuffer(buffer, byteIndex, typedArray.[[TypedArrayName]], true, SeqCst).
- If v = expected, then a. Perform ? SetValueInBuffer(buffer, byteIndex, typedArray.[[TypedArrayName]], replacement, true, SeqCst).
- Return v.
关键参数 SeqCst(Sequentially Consistent):所有 Atomics 操作使用顺序一致性内存顺序,这是最强的内存模型保证。
规范 §29.8:The Memory Model(内存模型)
ECMAScript 内存模型(ES2017 引入)基于 C++11 内存模型的子集:
候选执行(Candidate Execution) 是内存模型形式化的核心概念。规范定义了一个候选执行是事件集合和关系的元组:
CandidateExecution = {
EventSet: E, // 所有内存事件(读/写)的集合
AgentOrder: ao, // 每个 Agent 内部的程序序
ReadsBytesFrom: rbf, // 读取字节的来源(从哪个写入读取)
ReadsFrom: rf, // 更高级别的读取关系
HostSynchronizesWith: hsw,// 宿主定义的同步关系
SynchronizesWith: sw, // 基于 Atomics 操作的同步关系
HappensBefore: hb // happens-before 关系(sw 和 ao 的传递闭包)
}
规范要求:一个合法执行必须满足所有内存模型约束,包括:
- 一致性(Coherence):对同一地址的操作必须有全局一致的顺序
- 无数据竞争(Data Race Free):如果执行中存在数据竞争(两个非 Atomic 操作冲突),行为未定义
数据竞争的规范定义:
There is a data race in a candidate execution E if there exist two events (E1, E2) in EventSet such that:
- E1 and E2 are in different agents
- E1 and E2 access overlapping byte ranges
- At least one of E1, E2 is a non-atomic access
- It is not the case that E1 happens-before E2 or E2 happens-before E1
💎 Level 4 · 边界与陷阱
陷阱1:SharedArrayBuffer 的 Spectre 攻击背景(2018年禁用事件)
2018年1月,Spectre(幽灵)CPU 漏洞公开。攻击者利用 CPU 的投机执行和侧信道攻击,通过测量内存访问时间读取本应隔离的内存。SharedArrayBuffer 提供的高精度共享定时器(通过 Atomics.wait + 计数)使 Spectre 攻击变得容易:
// Spectre 攻击的概念性原理(演示用,已无法在现代浏览器执行):
// 1. 创建共享定时器
const sab = new SharedArrayBuffer(8);
const timer = new BigInt64Array(sab);
// Worker 中持续递增计数器
Atomics.add(timer, 0, 1n); // 在 Worker 中循环
// 主线程用作高精度时钟
const start = Atomics.load(timer, 0);
// ... 做一些操作 ...
const elapsed = Atomics.load(timer, 0) - start;
// elapsed 精度可达微秒级,足以用于侧信道攻击
解决方案: COOP(Cross-Origin-Opener-Policy)和 COEP(Cross-Origin-Embedder-Policy)通过将页面隔离在独立的浏览器进程中,阻止跨进程的内存访问,从而使 Spectre 攻击无效。
陷阱2:Atomics.wait 在主线程不可用
// 主线程中:
const sab = new SharedArrayBuffer(4);
const sa = new Int32Array(sab);
// 错误:在主线程调用 Atomics.wait 会抛出 TypeError
Atomics.wait(sa, 0, 0);
// TypeError: Cannot perform Atomics.wait on the main thread
// 正确1:在 Worker 中使用 wait
// worker.js:
Atomics.wait(sa, 0, 0); // 合法,Worker 的 [[CanBlock]] = true
// 正确2:在主线程使用 waitAsync(ES2024)
const result = await Atomics.waitAsync(sa, 0, 0).value;
// result 是 Promise<'ok' | 'not-equal' | 'timed-out'>
深层原因: 主线程的阻塞会冻结 UI,[[CanBlock]] = false 防止了这种情况。Atomics.waitAsync 提供了非阻塞替代方案。
陷阱3:数据竞争 vs 竞态条件的区别
// 数据竞争(Data Race):未定义行为(UB),规范不保证任何结果
const sab = new SharedArrayBuffer(4);
const sa = new Int32Array(sab);
// 线程A(Worker 1):
sa[0] = 1; // 非原子写
// 线程B(Worker 2):
const v = sa[0]; // 非原子读,与线程A的写冲突
// 这是数据竞争!结果完全未定义:
// - v 可能是 0(旧值)
// - v 可能是 1(新值)
// - v 可能是任何值(撕裂写入,partial write)
// - 程序可能崩溃或行为完全不可预测
// ──────────────────────────────────────────────
// 竞态条件(Race Condition):逻辑 bug,但行为是确定的
// 使用了 Atomics(没有数据竞争),但逻辑顺序错误:
const count = new Int32Array(sab);
// 线程A(Worker 1):
Atomics.add(count, 0, 1); // 原子操作,无数据竞争
const v = Atomics.load(count, 0);
// 但如果线程B同时也在 add,v 的具体值取决于调度
// 这是竞态条件:结果取决于执行顺序,但不会产生未定义行为
// 需要用锁或其他同步机制来避免
陷阱4:WebAssembly 线程与 SharedArrayBuffer 配合
WebAssembly 线程(wasm threads)通过 SharedArrayBuffer 实现,wasm 的线程模型比 JS 更接近 C/C++:
// 加载支持线程的 WASM 模块
const memory = new WebAssembly.Memory({
initial: 10, // 10 pages = 640KB
maximum: 100,
shared: true // 使用 SharedArrayBuffer 作为底层存储
});
const { instance } = await WebAssembly.instantiateStreaming(
fetch('threaded.wasm'),
{ env: { memory } }
);
// WASM 内存的底层是 SharedArrayBuffer
console.log(memory.buffer instanceof SharedArrayBuffer); // true
// WASM 线程可以直接访问同一块内存
// pthread(POSIX 线程)在 wasm 中通过 Worker + SharedArrayBuffer 模拟
注意事项:
- wasm 线程的 WASM 模块必须编译时开启 threads 特性(
-pthread或--enable-threads) - wasm 的
memory.atomic.wait等指令直接映射到Atomics.wait - wasm 线程的同步原语(mutex、semaphore)底层都是
Atomics.wait/notify
陷阱5:ArrayBuffer Transfer vs SharedArrayBuffer
// ArrayBuffer 可以 transfer(零拷贝转移所有权)
const buffer = new ArrayBuffer(1024);
const view = new Int32Array(buffer);
view[0] = 42;
// transfer 后,原始 buffer 变成 detached(无法访问)
worker.postMessage(buffer, [buffer]); // 转移所有权
console.log(buffer.byteLength); // 0(已 detached)
// ──────────────────────────────────────────────
// SharedArrayBuffer 不支持 transfer(本来就是共享的,没有"所有权"概念)
const sab = new SharedArrayBuffer(1024);
worker.postMessage(sab); // 直接共享,不需要 transfer
// sab 在主线程仍然有效(byteLength 仍然是 1024)
性能最佳实践:
- 一次性大数据传输:用
ArrayBuffer transfer(零拷贝,但所有权转移) - 持续共享数据:用
SharedArrayBuffer + Atomics(永久共享,需要同步) - 小量数据通信:用
postMessage(简单,但有复制开销)
小结
- SharedArrayBuffer 让多个 Worker 共享同一块内存,但共享内存需要 COOP + COEP 响应头(Spectre 缓解措施)。
- 未经同步的共享内存访问会产生数据竞争(Data Race),这是规范级别的未定义行为,不仅仅是逻辑错误。
Atomics.wait只能在[[CanBlock]] = true的 Agent(Dedicated Worker)中调用;主线程需要用Atomics.waitAsync(ES2024)。- Atomics 操作使用顺序一致性(Sequential Consistency)内存顺序,提供最强的多线程可见性保证。
- 数据竞争(Data Race,未定义行为)不同于竞态条件(Race Condition,逻辑 bug)——前者结果完全不可预测,后者结果确定但可能错误。