首页 / 文章

使用 SharedArrayBuffer 和 Atomics 在 Node.js 工作线程之间共享内存

既然 SharedArrayBuffer 已经足够好用,为什么 postMessage 仍会为每个 Node.js worker 提供独立副本?原子操作又是如何避免数据丢失并实现跨线程等待的?

发布者:REACTAPP.TOP阅读约 7 分钟1628 词

来源: The Day I Learned JavaScript Threads Don’t Actually Share Memory

Node.js 的工作线程是真正的操作系统线程,每个线程都有独立的 JavaScript 引擎实例。因此人们很容易认为它们会像 C 或 Java 中的线程那样共享内存,但一旦需要两个工作线程协同处理相同数据时,这种假设就会出问题。本文将说明 postMessage 实际上是如何处理传入数据的,何时真正需要共享内存,以及 Atomics 如何确保并发更新的正确性。

如果您是对工作线程不太熟悉,可以先阅读使用工作线程和进程池在 Node.js 中卸载 CPU 密集型任务;而本文的重点更为具体:工作线程创建后内存会发生什么变化。

postMessage 传递的是副本而非引用

实验开始时,主线程会从另一个文件中创建一个工作线程。

// main.js
const { Worker } = require("worker_threads");
const worker = new Worker("./worker.js");

随后它将一个普通数组发送给工作线程,稍后又会记录下该数组的副本。

const sharedLookingArray = [1, 2, 3];
worker.postMessage(sharedLookingArray);setTimeout(() => {
  console.log("Main thread's array:", sharedLookingArray); // still [1, 2, 3]
}, 100);

在工作线程端,它会接收该数组,添加一个元素后再记录结果。

// worker.js
const { parentPort } = require("worker_threads");
parentPort.on("message", (arr) => {
  arr.push(4);
  console.log("Worker's array:", arr); // [1, 2, 3, 4]
});

工作线程输出的是[1, 2, 3, 4],而无论等待多久,主线程仍然输出[1, 2, 3]。原因是postMessage会通过结构化克隆算法来处理这些值,这一机制在全局层面也以structuredClone的形式存在。数据在发送端被序列化,在接收端则重新构建为独立的对象,因此调用之后每个线程都会拥有一个独立且互不相关的数组。

这是一种经过深思熟虑且安全的默认设置。两个线程无法同时访问它们从未被赋予过的内存。其代价就是数据复制本身,复制量会随着数据大小增加,且每次发送消息时都需要进行复制。(对于大型二进制数据,Node还可以将ArrayBuffer作为可传输对象移至另一个线程,从而避免复制,但发送方将无法再使用该数据;这只是数据的传递,而非共享。)

何时应使用共享内存

当线程需要高频地协调处理相同数据,且来回传输数据的成本高于并行处理本身的工作量时,复制就不再是合适的折衷方案。针对这种情况,JavaScript提供了SharedArrayBuffer:它是一块原始内存区域,多个线程可直接引用,无需进行任何复制操作。

主线程会再次加载工作线程模块:

// main.js
const { Worker } = require("worker_threads");

接着它会分配一个4字节的共享缓冲区,这个大小足以存储一个32位整数,将该缓冲区封装在Int32Array视图之中,然后通过workerData将缓冲区传递给工作线程。

const sharedBuffer = new SharedArrayBuffer(4); // 4 bytes, one 32-bit integer
const sharedCounter = new Int32Array(sharedBuffer);const worker = new Worker("./worker.js", { workerData: { sharedBuffer } });

工作线程会在完全相同的缓冲区上构建自己的Int32Array视图:

// worker.js
const { workerData } = require("worker_threads");
const sharedCounter = new Int32Array(workerData.sharedBuffer);

从这一点开始,通过任一视图进行的写入操作都可以在另一个视图中看到,因为两者指向的是相同的字节数据。

sharedCounter[0] += 1; // this thread and the main thread are now touching the same memory

这与JavaScript并发机制的常规表现有很大不同。单个作用域中的代码都在同一线程上运行,因此那些常见的异步竞争情况——即两个async函数以出人意料的方式交错执行——实际上仍是同一线程在预定义的时间点轮流执行操作。而使用SharedArrayBuffer时,两个线程真的可以在同一时刻读取和写入相同的内存。

如今终于可以实现的丢失更新

考虑一下增加共享计数器的常见方法:

// naive, and genuinely unsafe with real shared memory
sharedCounter[0] = sharedCounter[0] + 1;

这看起来像是一个操作,但实际上包含三个步骤:读取当前值、加一、再将结果写回。如果另一个线程在当前线程读取和写入之间读取了计数器,两者会计算出相同的新值,从而导致一次增量丢失。不会抛出异常,也不会有日志记录;最终数值就会偏低。由于结果取决于两个操作系统线程的精确执行时机,这种错误可能在千次运行中才会出现一次,而加上日志记录后又会消失。

原子操作:对共享内存的不可分割操作

Atomics对象提供了对共享整数数组的操作,这些操作会作为一个整体执行。其他线程无法在原子操作进行到一半时观察或干扰它。

Atomics.add(sharedCounter, 0, 1); // read, increment, and write, as one indivisible step

如果每个线程都使用 Atomics.add 来递增,而非采用读-修改-写的方式,那么就不会有更新丢失的情况,因为引擎会保证对该元素的其他原子操作无法与当前操作交错执行。这一保障仅适用于所有写入操作都使用原子操作的情况;如果仍有线程采用普通的递增方式,就会再次出现竞争条件。无论是在共享内存上构建的计数器、锁还是队列等更复杂的结构,最终都是由这类在硬件层面具备原子性的操作构成的,而非“通常可用”的普通操作。

Atomics.wait 和 Atomics.notify:跨线程的阻塞机制

Atomics还提供了JavaScript所缺乏的功能:让一个线程完全停止执行,直到另一个线程唤醒它。在worker中,只要计数器仍为0,Atomics.wait就会持续阻塞:

// worker.js — waits for main thread to signal it's time to proceed
Atomics.wait(sharedCounter, 0, 0); // blocks this thread while sharedCounter[0] === 0
console.log("Signaled to continue, value is now:", sharedCounter[0]);

主线程存储新值后,再唤醒一个正在等待的线程:

// main.js — does the signaling
Atomics.store(sharedCounter, 0, 1);
Atomics.notify(sharedCounter, 0, 1); // wakes up one waiting thread

先存储再通知很重要。Atomics.wait会首先检查值,如果该值不再与预期值相同就会立即返回,因此在工作线程开始等待之前发送的信号不会丢失。

这是一种真正的阻塞式调用,在 JavaScript 中十分罕见,因为通常异步操作是通过事件循环回调来处理的。阻塞运行事件循环的线程会冻结该线程处理的所有其他任务,这就是为什么浏览器不允许在主线程上使用 Atomics.wait,也正因为如此,它通常应被用在工作线程中。(此外,浏览器还要求页面先实现跨源隔离,才能使用 SharedArrayBuffer;而 Node 则没有此类限制。)

实际应用场景:在工作线程池中实时显示进度

这是一个增加复杂性后带来好处的例子。多个受CPU限制的工作线程各自处理大型数据集的一部分,而主线程希望实时获取进度,同时又不想让每个工作线程为每条数据都发送消息。于是主线程创建了一个共享计数器,并将其缓冲区连同对应的数据块一起交给每个工作线程:

// main.js
const progress = new Int32Array(new SharedArrayBuffer(4));
const workers = chunks.map((chunk) =>
  new Worker("./process-chunk.js", { workerData: { chunk, progressBuffer: progress.buffer } })
);

随后主线程每隔半秒使用Atomics.load读取该计数器,这样可以确保读到的是完整且最新的数值:

setInterval(() => {
  console.log(`Processed ${Atomics.load(progress, 0)} items so far`);
}, 500);

每个工作线程都会为自己的数据块创建该共享缓冲区的独立视图:

// process-chunk.js
const { workerData } = require("worker_threads");
const progress = new Int32Array(workerData.progressBuffer);

在处理数据时,它们会以原子方式递增共享计数器:

for (const item of workerData.chunk) {
  processItem(item);
  Atomics.add(progress, 0, 1); // every worker safely increments the same shared counter
}

所有工作线程都直接写入相同的四个字节,无需为每条数据生成额外的消息开销,主线程也可以随时获取准确的总数,而无需等待其他线程上报。正是这种需要真正并行运行的CPU任务之间频繁且紧密协作的场景,才使得共享内存比复制更合适。

大多数代码不应使用这种方式

典型的后端Node代码很少需要这种机制。API服务器中的大部分并发操作都是I/O密集型的,单线程事件循环已经能够很好地处理它们。对于那些确实存在的CPU密集型任务,比如图像处理、数据转换或加密操作,使用普通的postMessage复制机制的工作线程通常就足够了。

SharedArrayBuffer和Atomics适用于更特定的场景:并行线程之间需要高频协调,而每次数据交换时都进行复制反而会成为瓶颈。在尚未确认存在此类问题之前就使用共享内存并不会让代码运行得更快,反而会引入复制机制本应避免的硬件级竞态条件。

关键要点

  • postMessage会创建数据的结构化副本,因此每个线程操作的都是独立的副本,数据修改不会相互影响。
  • SharedArrayBuffer允许多个线程通过类型化数组视图直接访问相同的字节数据。
  • 在共享内存上直接执行x = x + 1可能会导致更新丢失,每次写入时应使用Atomics.add等相关函数。
  • Atomics.wait会真正阻塞线程,应用于工作线程中;需与Atomics.store和Atomics.notify配合使用。
  • 只有在确认消息复制是性能瓶颈后,才应考虑使用共享内存。