解锁 JavaScript 真正的多线程能力。本篇综合指南涵盖 SharedArrayBuffer、Atomics、Web Workers 以及构建高性能 Web 应用所需的安全要求。
JavaScript SharedArrayBuffer:Web 并发编程深度解析
几十年来,JavaScript 的单线程特性既是其简单的根源,也是一个显著的性能瓶颈。事件循环模型对于大多数 UI 驱动的任务来说表现出色,但当面临计算密集型操作时就会捉襟见肘。长时间运行的计算会冻结浏览器,造成令人沮丧的用户体验。虽然 Web Workers 通过允许脚本在后台运行提供了一个部分解决方案,但它们也带来了自身的重大限制:低效的数据通信。
SharedArrayBuffer
(SAB) 应运而生,它是一项强大的功能,通过在 Web 线程之间引入真正的、底层的内存共享,从根本上改变了游戏规则。与 Atomics
对象配合使用,SAB 直接在浏览器中开启了一个高性能并发应用的新时代。然而,强大的能力也伴随着巨大的责任和复杂性。
本指南将带您深入探索 JavaScript 中的并发编程世界。我们将探讨为什么需要它,SharedArrayBuffer
和 Atomics
是如何工作的,您必须解决的关键安全考量,以及帮助您入门的实际示例。
旧世界:JavaScript 的单线程模型及其局限性
在我们领会解决方案的精妙之前,必须充分理解问题所在。传统上,浏览器中的 JavaScript 执行发生在单个线程上,通常称为“主线程”或“UI 线程”。
事件循环
主线程负责所有事情:执行您的 JavaScript 代码、渲染页面、响应用户交互(如点击和滚动)以及运行 CSS 动画。它使用事件循环来管理这些任务,该循环不断处理一个消息(任务)队列。如果一个任务需要很长时间才能完成,它会阻塞整个队列。其他任何事情都无法发生——UI 冻结,动画卡顿,页面变得无响应。
Web Workers:朝着正确方向迈出的一步
引入 Web Workers 就是为了缓解这个问题。Web Worker 本质上是一个在独立后台线程上运行的脚本。您可以将繁重的计算任务卸载到 worker,使主线程可以自由地处理用户界面。
主线程和 worker 之间的通信通过 postMessage()
API 进行。当您发送数据时,它由结构化克隆算法处理。这意味着数据被序列化、复制,然后在 worker 的上下文中反序列化。虽然有效,但这个过程对于大型数据集有明显的缺点:
- 性能开销:在线程之间复制数兆字节甚至数千兆字节的数据速度缓慢且消耗大量 CPU。
- 内存消耗:它会在内存中创建数据的副本,这对于内存受限的设备可能是一个主要问题。
想象一个浏览器内的视频编辑器。每秒 60 次将整个视频帧(可能达数兆字节)来回发送给 worker 进行处理,其成本将高得令人望而却步。这正是 SharedArrayBuffer
设计用来解决的问题。
游戏规则改变者:引入 SharedArrayBuffer
SharedArrayBuffer
是一个固定长度的原始二进制数据缓冲区,类似于 ArrayBuffer
。关键区别在于,SharedArrayBuffer
可以在多个线程(例如,主线程和一个或多个 Web Workers)之间共享。当您使用 postMessage()
“发送”一个 SharedArrayBuffer
时,您发送的不是副本,而是指向同一内存块的引用。
这意味着一个线程对缓冲区数据所做的任何更改,对于拥有其引用的所有其他线程都是立即可见的。这消除了昂贵的复制和序列化步骤,实现了近乎即时的数据共享。
可以这样理解:
- 使用
postMessage()
的 Web Workers:这就像两个同事通过来回发送电子邮件副本来处理一份文档。每次更改都需要发送一个全新的副本。 - 使用
SharedArrayBuffer
的 Web Workers:这就像两个同事在一个共享的在线编辑器(如 Google Docs)中处理同一份文档。更改对双方都是实时可见的。
共享内存的危险:竞态条件
即时内存共享功能强大,但它也引入了并发编程世界中的一个经典问题:竞态条件。
当多个线程试图同时访问和修改相同的共享数据,并且最终结果取决于它们执行的不可预测的顺序时,就会发生竞态条件。考虑一个存储在 SharedArrayBuffer
中的简单计数器。主线程和一个 worker 都想增加它。
- 线程 A 读取当前值为 5。
- 在线程 A 写入新值之前,操作系统暂停它并切换到线程 B。
- 线程 B 读取当前值,仍然是 5。
- 线程 B 计算新值 (6) 并将其写回内存。
- 系统切换回线程 A。它不知道线程 B 做了任何事。它从离开的地方继续,计算它的新值 (5 + 1 = 6) 并将 6 写回内存。
尽管计数器被增加了两次,但最终值是 6,而不是 7。这些操作不是原子性的——它们是可中断的,导致了数据丢失。这正是为什么您不能在没有其关键伙伴 Atomics
对象的情况下使用 SharedArrayBuffer
的原因。
共享内存的守护者:Atomics
对象
Atomics
对象提供了一组静态方法,用于在 SharedArrayBuffer
对象上执行原子操作。原子操作保证在执行期间不会被任何其他操作中断。它要么完全发生,要么根本不发生。
使用 Atomics
通过确保对共享内存的读-改-写操作安全执行,来防止竞态条件。
关键的 Atomics
方法
让我们看一些 Atomics
提供的最重要的方法。
Atomics.load(typedArray, index)
: 原子性地读取给定索引处的值并返回它。这确保您正在读取一个完整的、未损坏的值。Atomics.store(typedArray, index, value)
: 原子性地在给定索引处存储一个值并返回该值。这确保写入操作不被中断。Atomics.add(typedArray, index, value)
: 原子性地将一个值添加到给定索引处的值上。它返回该位置的原始值。这是x += value
的原子等价物。Atomics.sub(typedArray, index, value)
: 原子性地从给定索引处的值中减去一个值。Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: 这是一个强大的条件写入。它检查index
处的值是否等于expectedValue
。如果是,它会用replacementValue
替换它,并返回原始的expectedValue
。如果不是,它什么也不做,并返回当前值。这是实现更复杂的同步原语(如锁)的基本构建块。
同步:超越简单操作
有时您需要的不仅仅是安全的读取和写入。您需要线程之间进行协调和相互等待。一个常见的反模式是“忙等待”,即一个线程在一个紧密的循环中不断检查内存位置的变化。这会浪费 CPU 周期并消耗电池寿命。
Atomics
通过 wait()
和 notify()
提供了一个更高效的解决方案。
Atomics.wait(typedArray, index, value, timeout)
: 这告诉一个线程进入休眠状态。它检查index
处的值是否仍然是value
。如果是,线程将休眠,直到被Atomics.notify()
唤醒或达到可选的timeout
(以毫秒为单位)。如果index
处的值已经改变,它会立即返回。这非常高效,因为休眠的线程几乎不消耗 CPU 资源。Atomics.notify(typedArray, index, count)
: 这用于唤醒通过Atomics.wait()
在特定内存位置上休眠的线程。它最多会唤醒count
个等待的线程(如果未提供count
或为Infinity
,则唤醒所有线程)。
整合一切:实用指南
现在我们理解了理论,让我们逐步完成使用 SharedArrayBuffer
实现解决方案的步骤。
第 1 步:安全前提条件 - 跨源隔离
这是开发者最常遇到的绊脚石。出于安全原因,SharedArrayBuffer
仅在处于跨源隔离状态的页面中可用。这是一种安全措施,旨在缓解像 Spectre 这样的推测执行漏洞,这些漏洞可能利用(由共享内存实现的)高精度计时器来跨源泄露数据。
要启用跨源隔离,您必须配置您的 Web 服务器为您的主文档发送两个特定的 HTTP 标头:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
(COOP): 将您的文档的浏览上下文与其他文档隔离开来,防止它们直接与您的窗口对象交互。Cross-Origin-Embedder-Policy: require-corp
(COEP): 要求您页面加载的所有子资源(如图片、脚本和 iframe)必须要么来自同一源,要么通过Cross-Origin-Resource-Policy
标头或 CORS 明确标记为可跨源加载。
这可能很难设置,特别是当您依赖于不提供必要标头的第三方脚本或资源时。配置服务器后,您可以通过在浏览器控制台中检查 self.crossOriginIsolated
属性来验证您的页面是否已隔离。它必须为 true
。
第 2 步:创建和共享缓冲区
在您的主脚本中,您创建 SharedArrayBuffer
并使用像 Int32Array
这样的 TypedArray
在其上创建一个“视图”。
main.js:
// 首先检查是否跨源隔离!
if (!self.crossOriginIsolated) {
console.error("此页面未实现跨源隔离。SharedArrayBuffer 将不可用。");
} else {
// 创建一个可容纳一个 32 位整数的共享缓冲区。
const buffer = new SharedArrayBuffer(4);
// 在缓冲区上创建一个视图。所有原子操作都在此视图上进行。
const int32Array = new Int32Array(buffer);
// 初始化索引 0 处的值。
int32Array[0] = 0;
// 创建一个新的 worker。
const worker = new Worker('worker.js');
// 将共享缓冲区发送给 worker。这是引用传递,不是复制。
worker.postMessage({ buffer });
// 监听来自 worker 的消息。
worker.onmessage = (event) => {
console.log(`Worker 报告完成。最终值:${Atomics.load(int32Array, 0)}`);
};
}
第 3 步:在 Worker 中执行原子操作
worker 接收缓冲区,现在可以对其执行原子操作。
worker.js:
self.onmessage = (event) => {
const { buffer } = event.data;
const int32Array = new Int32Array(buffer);
console.log("Worker 收到了共享缓冲区。");
// 让我们执行一些原子操作。
for (let i = 0; i < 1000000; i++) {
// 安全地增加共享值。
Atomics.add(int32Array, 0, 1);
}
console.log("Worker 完成了增加操作。");
// 向主线程发信号表示我们已完成。
self.postMessage({ done: true });
};
第 4 步:更高级的示例 - 使用同步进行并行求和
让我们解决一个更现实的问题:使用多个 worker 对一个非常大的数字数组求和。我们将使用 Atomics.wait()
和 Atomics.notify()
进行高效同步。
我们的共享缓冲区将有三个部分:
- 索引 0:一个状态标志(0 = 处理中,1 = 已完成)。
- 索引 1:一个已完成任务的 worker 计数器。
- 索引 2:最终的和。
main.js:
if (self.crossOriginIsolated) {
const NUM_WORKERS = 4;
const DATA_SIZE = 10_000_000;
// [状态, 已完成的worker数, 结果低位, 结果高位]
// 我们使用两个 32 位整数来存储结果,以避免大数求和时发生溢出。
const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4个整数
const sharedArray = new Int32Array(sharedBuffer);
// 生成一些随机数据进行处理
const data = new Uint8Array(DATA_SIZE);
for (let i = 0; i < DATA_SIZE; i++) {
data[i] = Math.floor(Math.random() * 10);
}
const chunkSize = Math.ceil(DATA_SIZE / NUM_WORKERS);
for (let i = 0; i < NUM_WORKERS; i++) {
const worker = new Worker('sum_worker.js');
const start = i * chunkSize;
const end = Math.min(start + chunkSize, DATA_SIZE);
// 为 worker 的数据块创建一个非共享的视图
const dataChunk = data.subarray(start, end);
worker.postMessage({
sharedBuffer,
dataChunk // 这是被复制的
});
}
console.log('主线程现在等待 workers 完成...');
// 等待索引 0 的状态标志变为 1
// 这比 while 循环好得多!
Atomics.wait(sharedArray, 0, 0); // 如果 sharedArray[0] 为 0 则等待
console.log('主线程被唤醒!');
const finalSum = Atomics.load(sharedArray, 2);
console.log(`最终的并行求和结果是: ${finalSum}`);
} else {
console.error('页面未实现跨源隔离。');
}
sum_worker.js:
self.onmessage = ({ data }) => {
const { sharedBuffer, dataChunk } = data;
const sharedArray = new Int32Array(sharedBuffer);
// 计算此 worker 数据块的总和
let localSum = 0;
for (let i = 0; i < dataChunk.length; i++) {
localSum += dataChunk[i];
}
// 将本地总和原子性地加到共享总数上
Atomics.add(sharedArray, 2, localSum);
// 原子性地增加“已完成 worker”计数器
const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;
// 如果这是最后一个完成的 worker...
const NUM_WORKERS = 4; // 在实际应用中应该传递进来
if (finishedCount === NUM_WORKERS) {
console.log('最后一个 worker 已完成。通知主线程。');
// 1. 将状态标志设置为 1(完成)
Atomics.store(sharedArray, 0, 1);
// 2. 通知正在等待索引 0 的主线程
Atomics.notify(sharedArray, 0, 1);
}
};
真实世界的用例和应用
这项强大但复杂的技术究竟在哪些地方能发挥作用?它在需要对大型数据集进行繁重的、可并行化计算的应用中表现出色。
- WebAssembly (Wasm):这是杀手级用例。像 C++、Rust 和 Go 这样的语言对多线程有成熟的支持。Wasm 允许开发者将这些现有的高性能、多线程应用(如游戏引擎、CAD 软件和科学模型)编译到浏览器中运行,使用
SharedArrayBuffer
作为线程通信的底层机制。 - 浏览器内数据处理:大规模数据可视化、客户端机器学习模型推理以及处理海量数据的科学模拟都可以得到显著加速。
- 媒体编辑:对高分辨率图像应用滤镜或对声音文件进行音频处理,可以分解成块,由多个 worker 并行处理,为用户提供实时反馈。
- 高性能游戏:现代游戏引擎严重依赖多线程进行物理计算、AI 和资源加载。
SharedArrayBuffer
使得构建完全在浏览器中运行的主机质量游戏成为可能。
挑战与最终考量
虽然 SharedArrayBuffer
是变革性的,但它并非万能药。它是一个需要小心处理的底层工具。
- 复杂性:并发编程是出了名的困难。调试竞态条件和死锁可能极具挑战性。您必须以不同的方式思考如何管理应用程序的状态。
- 死锁:当两个或多个线程被永久阻塞,每个线程都在等待对方释放资源时,就会发生死锁。如果您不正确地实现复杂的锁定机制,就可能发生这种情况。
- 安全开销:跨源隔离要求是一个重大的障碍。如果第三方服务、广告和支付网关不支持必要的 CORS/CORP 标头,它可能会破坏这些集成。
- 并非适用于所有问题:对于简单的后台任务或 I/O 操作,使用
postMessage()
的传统 Web Worker 模型通常更简单且足够。只有当您面临涉及大量数据的、明确的 CPU 密集型瓶颈时,才应使用SharedArrayBuffer
。
结论
SharedArrayBuffer
与 Atomics
和 Web Workers 相结合,代表了 Web 开发的范式转变。它打破了单线程模型的界限,将一类功能强大、性能卓越且复杂的应用程序引入浏览器。它使 Web 平台在计算密集型任务方面,与原生应用开发站在了更平等的地位上。
进入并发 JavaScript 的旅程充满挑战,要求在状态管理、同步和安全方面采取严谨的方法。但对于那些希望突破 Web 可能性界限的开发者来说——从实时音频合成到复杂的 3D 渲染和科学计算——掌握 SharedArrayBuffer
不再仅仅是一个选项;它是构建下一代 Web 应用程序的必备技能。