中文

解锁 JavaScript 真正的多线程能力。本篇综合指南涵盖 SharedArrayBuffer、Atomics、Web Workers 以及构建高性能 Web 应用所需的安全要求。

JavaScript SharedArrayBuffer:Web 并发编程深度解析

几十年来,JavaScript 的单线程特性既是其简单的根源,也是一个显著的性能瓶颈。事件循环模型对于大多数 UI 驱动的任务来说表现出色,但当面临计算密集型操作时就会捉襟见肘。长时间运行的计算会冻结浏览器,造成令人沮丧的用户体验。虽然 Web Workers 通过允许脚本在后台运行提供了一个部分解决方案,但它们也带来了自身的重大限制:低效的数据通信。

SharedArrayBuffer (SAB) 应运而生,它是一项强大的功能,通过在 Web 线程之间引入真正的、底层的内存共享,从根本上改变了游戏规则。与 Atomics 对象配合使用,SAB 直接在浏览器中开启了一个高性能并发应用的新时代。然而,强大的能力也伴随着巨大的责任和复杂性。

本指南将带您深入探索 JavaScript 中的并发编程世界。我们将探讨为什么需要它,SharedArrayBufferAtomics 是如何工作的,您必须解决的关键安全考量,以及帮助您入门的实际示例。

旧世界:JavaScript 的单线程模型及其局限性

在我们领会解决方案的精妙之前,必须充分理解问题所在。传统上,浏览器中的 JavaScript 执行发生在单个线程上,通常称为“主线程”或“UI 线程”。

事件循环

主线程负责所有事情:执行您的 JavaScript 代码、渲染页面、响应用户交互(如点击和滚动)以及运行 CSS 动画。它使用事件循环来管理这些任务,该循环不断处理一个消息(任务)队列。如果一个任务需要很长时间才能完成,它会阻塞整个队列。其他任何事情都无法发生——UI 冻结,动画卡顿,页面变得无响应。

Web Workers:朝着正确方向迈出的一步

引入 Web Workers 就是为了缓解这个问题。Web Worker 本质上是一个在独立后台线程上运行的脚本。您可以将繁重的计算任务卸载到 worker,使主线程可以自由地处理用户界面。

主线程和 worker 之间的通信通过 postMessage() API 进行。当您发送数据时,它由结构化克隆算法处理。这意味着数据被序列化、复制,然后在 worker 的上下文中反序列化。虽然有效,但这个过程对于大型数据集有明显的缺点:

想象一个浏览器内的视频编辑器。每秒 60 次将整个视频帧(可能达数兆字节)来回发送给 worker 进行处理,其成本将高得令人望而却步。这正是 SharedArrayBuffer 设计用来解决的问题。

游戏规则改变者:引入 SharedArrayBuffer

SharedArrayBuffer 是一个固定长度的原始二进制数据缓冲区,类似于 ArrayBuffer。关键区别在于,SharedArrayBuffer 可以在多个线程(例如,主线程和一个或多个 Web Workers)之间共享。当您使用 postMessage()“发送”一个 SharedArrayBuffer 时,您发送的不是副本,而是指向同一内存块的引用。

这意味着一个线程对缓冲区数据所做的任何更改,对于拥有其引用的所有其他线程都是立即可见的。这消除了昂贵的复制和序列化步骤,实现了近乎即时的数据共享。

可以这样理解:

共享内存的危险:竞态条件

即时内存共享功能强大,但它也引入了并发编程世界中的一个经典问题:竞态条件

当多个线程试图同时访问和修改相同的共享数据,并且最终结果取决于它们执行的不可预测的顺序时,就会发生竞态条件。考虑一个存储在 SharedArrayBuffer 中的简单计数器。主线程和一个 worker 都想增加它。

  1. 线程 A 读取当前值为 5。
  2. 在线程 A 写入新值之前,操作系统暂停它并切换到线程 B。
  3. 线程 B 读取当前值,仍然是 5。
  4. 线程 B 计算新值 (6) 并将其写回内存。
  5. 系统切换回线程 A。它不知道线程 B 做了任何事。它从离开的地方继续,计算它的新值 (5 + 1 = 6) 并将 6 写回内存。

尽管计数器被增加了两次,但最终值是 6,而不是 7。这些操作不是原子性的——它们是可中断的,导致了数据丢失。这正是为什么您不能在没有其关键伙伴 Atomics 对象的情况下使用 SharedArrayBuffer 的原因。

共享内存的守护者:Atomics 对象

Atomics 对象提供了一组静态方法,用于在 SharedArrayBuffer 对象上执行原子操作。原子操作保证在执行期间不会被任何其他操作中断。它要么完全发生,要么根本不发生。

使用 Atomics 通过确保对共享内存的读-改-写操作安全执行,来防止竞态条件。

关键的 Atomics 方法

让我们看一些 Atomics 提供的最重要的方法。

同步:超越简单操作

有时您需要的不仅仅是安全的读取和写入。您需要线程之间进行协调和相互等待。一个常见的反模式是“忙等待”,即一个线程在一个紧密的循环中不断检查内存位置的变化。这会浪费 CPU 周期并消耗电池寿命。

Atomics 通过 wait()notify() 提供了一个更高效的解决方案。

整合一切:实用指南

现在我们理解了理论,让我们逐步完成使用 SharedArrayBuffer 实现解决方案的步骤。

第 1 步:安全前提条件 - 跨源隔离

这是开发者最常遇到的绊脚石。出于安全原因,SharedArrayBuffer 仅在处于跨源隔离状态的页面中可用。这是一种安全措施,旨在缓解像 Spectre 这样的推测执行漏洞,这些漏洞可能利用(由共享内存实现的)高精度计时器来跨源泄露数据。

要启用跨源隔离,您必须配置您的 Web 服务器为您的主文档发送两个特定的 HTTP 标头:


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

这可能很难设置,特别是当您依赖于不提供必要标头的第三方脚本或资源时。配置服务器后,您可以通过在浏览器控制台中检查 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() 进行高效同步。

我们的共享缓冲区将有三个部分:

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);
  }
};

真实世界的用例和应用

这项强大但复杂的技术究竟在哪些地方能发挥作用?它在需要对大型数据集进行繁重的、可并行化计算的应用中表现出色。

挑战与最终考量

虽然 SharedArrayBuffer 是变革性的,但它并非万能药。它是一个需要小心处理的底层工具。

  1. 复杂性:并发编程是出了名的困难。调试竞态条件和死锁可能极具挑战性。您必须以不同的方式思考如何管理应用程序的状态。
  2. 死锁:当两个或多个线程被永久阻塞,每个线程都在等待对方释放资源时,就会发生死锁。如果您不正确地实现复杂的锁定机制,就可能发生这种情况。
  3. 安全开销:跨源隔离要求是一个重大的障碍。如果第三方服务、广告和支付网关不支持必要的 CORS/CORP 标头,它可能会破坏这些集成。
  4. 并非适用于所有问题:对于简单的后台任务或 I/O 操作,使用 postMessage() 的传统 Web Worker 模型通常更简单且足够。只有当您面临涉及大量数据的、明确的 CPU 密集型瓶颈时,才应使用 SharedArrayBuffer

结论

SharedArrayBufferAtomics 和 Web Workers 相结合,代表了 Web 开发的范式转变。它打破了单线程模型的界限,将一类功能强大、性能卓越且复杂的应用程序引入浏览器。它使 Web 平台在计算密集型任务方面,与原生应用开发站在了更平等的地位上。

进入并发 JavaScript 的旅程充满挑战,要求在状态管理、同步和安全方面采取严谨的方法。但对于那些希望突破 Web 可能性界限的开发者来说——从实时音频合成到复杂的 3D 渲染和科学计算——掌握 SharedArrayBuffer 不再仅仅是一个选项;它是构建下一代 Web 应用程序的必备技能。