💻ArrayBuffer 与 SharedArrayBuffer 的内存隔离机制:共享,并不意味着混乱

514 阅读5分钟

引言

如果你曾在浏览器中使用 Web Worker 或 Node.js 的 Worker Thread 编写多线程代码,那么你一定遇到过这两个概念:

  • ArrayBuffer
  • SharedArrayBuffer

它们的名字看起来只差一个 “Shared”,实则背后承载的是线程安全模型内存隔离策略,甚至涉及到了浏览器的安全沙箱机制

本篇文章将带你深入探索:

  • ArrayBufferSharedArrayBuffer 的设计目的
  • 两者在内存隔离上的根本差异
  • 如何在 Worker 中实现跨线程通信而不破坏数据一致性
  • 浏览器为何曾禁用 SharedArrayBuffer,又如何通过 COOP/COEP 回归
  • 实际案例:如何使用 SharedArrayBuffer 实现一个线程安全的计数器

一、背景知识:JS世界本来是单线程的

JavaScript 的运行模型是单线程的,意味着同一时间只能执行一段代码。为了避免 UI 卡顿,HTML5 引入了 Web Worker(浏览器)和 Worker Thread(Node.js),用来并行执行任务

问题是,多线程就需要解决一个问题:数据怎么传?

  • 主线程不能直接操作 Worker 里的变量。
  • Worker 也不能“偷看”主线程的上下文。
  • 所以只能靠:postMessage()

postMessage() 是通过 结构化克隆算法 来传输数据的:

  • 每传一次,就复制一次;
  • 对大数据(二进制图像、音频、文件)极其低效;
  • 无法实现真正的“共享”。

二、ArrayBuffer:传得了,看不到

ArrayBuffer 是 JavaScript 操作二进制数据的底层数据结构。它提供了一段连续的、固定长度的原始内存,用于存储数据。

const buffer = new ArrayBuffer(8); // 8字节
const view = new Uint8Array(buffer);
view[0] = 42;

特点

  • 可用于 TypedArray(如 Uint8Array, Float32Array)创建视图;
  • 可通过 postMessage() 传入 Worker,但默认是复制
  • 也可以作为“可转移对象”发送,发送后源线程失去访问权
worker.postMessage(buffer, [buffer]); // Transfer

这个 “可转移对象机制” 是一种内存拥有权的转移,不是共享。发送方再想访问?对不起,byteLength = 0

所以 ArrayBuffer 是一种“可搬家,但不共居”的模型。


三、SharedArrayBuffer:共享内存的特洛伊木马

SharedArrayBuffer 的出现正是为了解决上述问题:在多个线程间共享一块内存,同时保持数据一致性。

const shared = new SharedArrayBuffer(8);
const view = new Int32Array(shared);

view[0] = 123;

你可以把它传给多个 Worker,无需复制,大家直接指向同一块物理内存。

worker.postMessage(shared); // 不需要 transfer 列表

关键差异

特性ArrayBufferSharedArrayBuffer
是否共享否(复制/转移)是(直接引用)
是否可转移否(共享无须转移)
可否在多个线程访问是(并发访问)
是否线程安全否(需配合 Atomics)
是否可直接修改

注意:SharedArrayBuffer 本身并不保证线程安全,你仍需使用 Atomics 提供的原子操作。


四、内存隔离机制详解

ArrayBuffer 的隔离策略

当你通过 postMessage() 传递 ArrayBuffer

  • 若不指定 transfer,则克隆;
  • 若指定 transfer 列表,则把所有权转移,发送方“断电”;
  • 两个线程的视图不会再指向同一块内存空间。

这是浏览器为 JS 实现的“堆隔离”策略 —— 避免线程交叉污染。

SharedArrayBuffer 的共享实现

SharedArrayBuffer 本质上由浏览器的底层内存模型支持:

  • 使用共享内存区域(通常是平台提供的共享页内存)
  • 多个线程可以访问相同地址空间
  • 浏览器对该内存区域做了内存页锁定(Page Locking)处理,避免被 GC 误删

更关键的是,它跳过了结构化克隆的隔离机制,完全依赖开发者自己控制一致性。


五、为什么浏览器曾禁用 SharedArrayBuffer?

一个关键词:Spectre

Spectre 是一种处理器层面的旁路攻击漏洞,攻击者可以利用预测执行 + 缓存时序分析,越权读取别的线程的内存数据

SharedArrayBuffer 刚好是“极佳入口”:

  • 它共享物理内存;
  • 可被 Worker 高速读写;
  • 可被利用发起精确的时序测量攻击。

于是浏览器在 2018 年紧急禁用了它,直到提供了对应的安全缓解机制:

安全防护机制:COOP + COEP

  • COOP(Cross-Origin-Opener-Policy)
  • COEP(Cross-Origin-Embedder-Policy)

配置如下 HTTP Header 才能启用 SharedArrayBuffer

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

你可以在浏览器控制台看到报错提示,不配这个头,SharedArrayBuffer 根本不能用。


六、原子性保障:Atomics 是你的好兄弟

SharedArrayBuffer 让线程能访问同一内存,但会有竞争条件(Race Condition)

举例:

// 两个线程同时执行
view[0] += 1;

可能在底层变成:

  • 线程 A 读了 view[0] = 1
  • 线程 B 也读了 view[0] = 1
  • A 写入 2
  • B 也写入 2
  • 最终结果是 2,但我们期望是 3

解决办法是使用 Atomics

Atomics.add(view, 0, 1); // 原子加 1

它保证了内存操作的原子性,底层依赖 CPU 的原子指令(如 x86 的 LOCK 前缀)。

你还能用:

  • Atomics.wait() + Atomics.notify() 实现同步阻塞
  • Atomics.compareExchange() 实现锁竞争
  • Atomics.store/load 实现可见性保障

这是 JavaScript 走向并发编程的重要一步。


七、实际案例:线程安全的计数器

主线程:

const shared = new SharedArrayBuffer(4); // 4 字节 = 1个 Int32
const counter = new Int32Array(shared);

const worker = new Worker('./worker.js');
worker.postMessage(shared);

worker.js:

onmessage = function(e) {
  const counter = new Int32Array(e.data);

  for (let i = 0; i < 10000; i++) {
    Atomics.add(counter, 0, 1);
  }

  postMessage('done');
}

这个方式可以安全地在多个线程对同一个变量进行累加操作,结果是稳定的、不会丢失。


八、未来展望:结构共享的新时代?

SharedArrayBufferAtomics 已经是 JS 并发编程的重要组成部分。未来的方向包括:

  • WebAssembly 将进一步依赖 SharedArrayBuffer 进行跨模块内存共享;
  • JS 多线程库(如 ComlinkThreads.js)会对其做封装;
  • React、Vue 等框架也可能会引入基于 SAB 的数据流模型优化。

但与此同时,安全机制始终是底线,未来浏览器将持续强化隔离能力和 CSP 限制。


总结

  • ArrayBuffer 是单线程内存数据容器,可传可转,但不共享;
  • SharedArrayBuffer 是多线程共享内存工具,但需开发者确保一致性;
  • 内存隔离机制是浏览器基于结构化克隆 + 所有权控制实现的;
  • 使用 SAB 时必须启用 COOP/COEP,防止 Spectre 攻击;
  • 实现线程安全操作,需要 Atomics 提供原子性保障。