第 29 章

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.crossOriginIsolatedtrue,可以使用 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]] 属性:

规范 §29.4:Atomics Object

规范定义了 Atomics.compareExchange 的语义(§29.4.4):

Atomics.compareExchange ( typedArray, index, expectedValue, replacementValue )

  1. Let buffer be ? ValidateIntegerTypedArray(typedArray).
  2. Let i be ? ValidateAtomicAccess(typedArray, index).
  3. Let expected be ? ToInteger(expectedValue).
  4. Let replacement be ? ToInteger(replacementValue).
  5. Let elementSize be the Element Size value specified in Table 68 for typedArray.[[TypedArrayName]].
  6. Let offset be typedArray.[[ByteOffset]].
  7. Let byteIndex be (i × elementSize) + offset.
  8. Let v be ? GetValueFromBuffer(buffer, byteIndex, typedArray.[[TypedArrayName]], true, SeqCst).
  9. If v = expected, then a. Perform ? SetValueInBuffer(buffer, byteIndex, typedArray.[[TypedArrayName]], replacement, true, SeqCst).
  10. 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 的传递闭包)
}

规范要求:一个合法执行必须满足所有内存模型约束,包括:

数据竞争的规范定义:

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 模拟

注意事项:

  1. wasm 线程的 WASM 模块必须编译时开启 threads 特性(-pthread--enable-threads
  2. wasm 的 memory.atomic.wait 等指令直接映射到 Atomics.wait
  3. 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)

性能最佳实践:

小结

  1. SharedArrayBuffer 让多个 Worker 共享同一块内存,但共享内存需要 COOP + COEP 响应头(Spectre 缓解措施)。
  2. 未经同步的共享内存访问会产生数据竞争(Data Race),这是规范级别的未定义行为,不仅仅是逻辑错误。
  3. Atomics.wait 只能在 [[CanBlock]] = true 的 Agent(Dedicated Worker)中调用;主线程需要用 Atomics.waitAsync(ES2024)。
  4. Atomics 操作使用顺序一致性(Sequential Consistency)内存顺序,提供最强的多线程可见性保证。
  5. 数据竞争(Data Race,未定义行为)不同于竞态条件(Race Condition,逻辑 bug)——前者结果完全不可预测,后者结果确定但可能错误。
本章评分
4.9  / 5  (3 评分)

💬 留言讨论