从数据拷贝到内存共享:为什么需要 SharedArrayBuffer?
很多前端工程师对 Web Worker 的第一印象是“好用但不够快”。当你需要将一个大数组传递给 Worker 进行复杂计算时,postMessage 会触发一次深拷贝。对于一个 1000 万像素的图像数据(约 30MB),这个拷贝过程本身就可能消耗数十毫秒,更不用说后续的计算了。这种基于消息传递的模型,本质上是将并发安全建立在数据隔离之上,代价是巨大的内存和时间开销。
SharedArrayBuffer 的出现,就是为了打破这堵墙。它允许主线程和多个 Worker 线程直接读写同一块物理内存。数据修改是立即可见的,不再需要序列化和反序列化。这听起来像是回到了 C++ 或 Java 的多线程世界,而 JavaScript 通过引入 Atomics 这个“交通警察”,来确保在这个共享内存的世界里,程序行为依然是确定和安全的。
理解 SharedArrayBuffer 与类型化数组视图
SharedArrayBuffer 本身只是一块原始的、无类型的二进制内存缓冲区。你不能直接操作它,就像你不能直接操作一块没有文件系统的硬盘一样。要使用它,你需要一个“视图”。
在 JavaScript 的并发模型中,这通常意味着使用类型化数组(Typed Array)作为视图。例如,Int32Array、Float64Array 或 Uint8ClampedArray。这些视图为你提供了读写这块内存的“透镜”,决定了如何解释其中的二进制数据。
// 创建一块共享内存,大小为 4 字节(一个32位整数)
const sharedBuffer = new SharedArrayBuffer(4);
// 创建一个 Int32Array 视图来操作这块内存
const sharedArray = new Int32Array(sharedBuffer);
sharedArray[0] = 42; // 写入数据
关键在于,你可以将这个 sharedBuffer(而不是数据副本)通过 postMessage 传递给 Worker。在 Worker 内部,用同样的视图类型去“映射”这块内存,就能立刻看到主线程写入的值。
竞态条件:共享内存的“阿喀琉斯之踵”
一旦内存被共享,经典的并发问题便随之而来。最核心的问题是竞态条件。考虑一个简单的计数器自增场景:两个线程同时读取当前值(比如 100),各自加 1,然后写回。理想结果是 102,但由于操作交错,实际可能只变成 101。
这是因为自增(value++)不是一个原子操作。在机器指令层面,它至少包含“读取-计算-写入”三个步骤。在没有同步机制的情况下,这些步骤可能被其他线程打断。
这就是 Atomics API 必须存在的原因。它提供了一系列不可中断的原子操作,确保在单个原子操作执行期间,内存状态对其他线程是“冻结”的,从而得到确定性的结果。
Atomics 的核心武器库
Atomics 对象提供了一系列静态方法,主要分为两类:算术/位运算原子操作,以及线程同步原语。
- 算术与位运算:
Atomics.add(),Atomics.sub(),Atomics.and(),Atomics.or(),Atomics.xor()。这些方法会原子性地完成运算并返回旧值。 - 读写与比较交换:
Atomics.load()和Atomics.store()提供原子的读写。Atomics.compareExchange()是实现无锁数据结构(如队列、栈)的基石,它仅在当前值等于预期值时进行交换。 - 线程等待与唤醒:
Atomics.wait()和Atomics.notify()(旧版为wake)模拟了操作系统级的条件变量,允许线程高效地等待某个条件成立,而不是忙等待,这对于构建锁、信号量等高级同步结构至关重要。
下表对比了普通操作与原子操作在共享内存场景下的区别:
| 操作类型 | 典型代码 | 线程安全性 | 结果确定性 | 适用场景 |
|---|---|---|---|---|
| 普通操作 | sharedArray[0]++ |
不安全,存在竞态条件 | 不确定 | 单线程,或只读多线程 |
| 原子操作 | Atomics.add(sharedArray, 0, 1) |
安全 | 确定 | 多线程读写共享内存 |
实战:构建一个线程安全的高性能计数器
让我们用一个简单的例子,把概念串联起来。假设我们需要在多个 Worker 中并行处理任务,并汇总一个全局的完成计数。
主线程代码:
// main.js
// 创建共享内存和一个32位整数视图
const sab = new SharedArrayBuffer(4);
const counter = new Int32Array(sab);
// 创建多个Worker并传递共享缓冲区
const worker1 = new Worker('worker.js');
const worker2 = new Worker('worker.js');
worker1.postMessage(sab);
worker2.postMessage(sab);
// 监听Worker完成,并读取最终结果
setTimeout(() => {
// 使用原子操作读取最终值
const finalCount = Atomics.load(counter, 0);
console.log(`所有Worker完成任务总数: ${finalCount}`);
}, 5000);
Worker 线程代码:
// worker.js
self.onmessage = function(e) {
const sharedBuffer = e.data;
const counter = new Int32Array(sharedBuffer);
// 模拟每个Worker完成1000次任务
for (let i = 0; i < 1000; i++) {
// 使用原子加操作,确保计数准确
Atomics.add(counter, 0, 1);
// 模拟一些工作
performTask(i);
}
};
function performTask(taskId) {
// 一些计算密集型任务...
}
在这个例子中,无论两个 Worker 如何交错执行,最终的 counter[0] 都将是确定的 2000。如果将 Atomics.add 换成普通的 counter[0]++,结果将无法预测。
高级同步:使用 Wait 和 Notify 实现简单锁
对于更复杂的同步场景,比如保护一段临界区代码,我们可以用 Atomics 实现一个简单的自旋锁。虽然这不是最高效的锁,但它清晰地展示了原理。
// 使用一个共享的 Int32Array 作为锁状态标志位
const lockBuffer = new SharedArrayBuffer(4);
const lock = new Int32Array(lockBuffer);
const UNLOCKED = 0;
const LOCKED = 1;
// 加锁函数
function acquireLock() {
let oldValue;
do {
// 不断尝试将状态从 UNLOCKED (0) 改为 LOCKED (1)
oldValue = Atomics.compareExchange(lock, 0, UNLOCKED, LOCKED);
// 如果旧值不是 UNLOCKED,说明锁已被其他线程持有,可以等待
if (oldValue !== UNLOCKED) {
Atomics.wait(lock, 0, LOCKED); // 线程在此休眠,直到被唤醒
}
} while (oldValue !== UNLOCKED); // 直到成功获得锁
}
// 解锁函数
function releaseLock() {
// 将锁状态改回 UNLOCKED
Atomics.store(lock, 0, UNLOCKED);
// 唤醒一个正在此锁上等待的线程
Atomics.notify(lock, 0, 1);
}
这种模式在需要协调多个 Worker 对共享资源(如一个共享的任务队列)进行顺序访问时非常有用。
安全限制与部署考量
SharedArrayBuffer 的能力也带来了安全挑战,特别是曾被用于 Spectre 这类侧信道攻击。因此,浏览器对其有严格的启用条件:
- 跨源隔离:你的页面必须通过设置
Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Embedder-Policy: require-corp响应头来启用跨源隔离。 - 主线程限制:
Atomics.wait()不能在浏览器主线程中使用,以避免界面冻结。
这意味着,这项技术更适合在受控的、对性能有极致要求的 Web 应用内部使用,例如:
- Web 版音视频编辑器:多个 Worker 并行处理不同的视频帧或音频轨道。
- 在线科学计算或数据可视化:对大型数据集进行并行处理。
- 复杂的 Web 游戏引擎:将物理计算、AI 逻辑分配到不同线程。
如果你的应用无法满足跨源隔离策略,或者并发需求没那么强烈,传统的 postMessage 配合 Transferable 对象可能仍是更简单安全的选择。
总结:能力与责任并存
SharedArrayBuffer 和 Atomics 将 JavaScript 带入了真正的多线程编程领域。它解决了高性能并发中最大的数据移动开销问题,但同时也将内存同步、竞态条件这些底层并发难题带给了前端开发者。
在决定使用它之前,先问自己几个问题:你的计算瓶颈真的是数据拷贝吗?你的团队是否准备好应对更复杂的多线程调试?你的应用环境能否满足安全策略要求?
如果答案是肯定的,那么这套工具链将为你打开一扇新的大门,让你能够构建出性能堪比原生应用的大型 Web 项目。它不再是实验室特性,而是已经回归主流浏览器、等待被用于解决真实高性能问题的利器。关键在于,像使用任何强大的工具一样,理解其原理,敬畏其复杂性,并在明确的边界内发挥其价值。
原创文章,作者:,如若转载,请注明出处:https://fudengji.cn/article/102/