Node.js开发者习惯了在单线程中执行JavaScript。即使通过worker_threads
引入了多线程,你仍然可以感到相当安全。
然而,当你在多线程中添加共享资源时,情况就变了。事实上,这是所有软件工程中最具挑战性的主题之一。我说的是多线程编程。
幸运的是,JavaScript提供了内置的抽象来减轻跨多个线程共享资源的问题。这种机制叫做Atomics。
在本文中,你将了解Node.js中共享资源是什么样子,以及Atomics
API如何帮助我们防止疯狂的竞态条件。
多线程之间的共享内存
让我们从理解可传输对象开始。
可传输对象是可以从一个执行上下文转移到另一个执行上下文而不保留原始上下文资源的对象。
执行上下文是JavaScript代码可以执行的地方。为了便于理解,让我们假设执行上下文等同于工作线程,因为每个线程确实是一个单独的执行上下文。
例如,ArrayBuffer
是一个可传输对象。它由两部分组成:原始分配的内存和对此内存的JavaScript句柄。你可以阅读关于JavaScript中的缓冲区的文章,以了解更多关于这个话题的信息。
每当我们将ArrayBuffer
从主线程转移到工作线程时,原始内存和JavaScript对象都会在工作线程中重新创建。你无法访问工作线程内部的ArrayBuffer
的相同对象引用或底层内存。
不同线程之间共享资源的唯一方法是使用SharedArrayBuffer
。
顾名思义,它被设计为共享。我们认为这个缓冲区是一个不可传输的对象。如果你尝试将SharedArrayBuffer
从主线程传递到工作线程,只有JavaScript对象会被重新创建,但它所引用的内存区域是相同的。
虽然SharedArrayBuffer
是一个独特而强大的API,但它是有代价的。
正如本叔叔告诉我们的:
当我们在多个线程之间共享资源时,我们暴露在全新的、令人讨厌的竞态条件世界中。
共享资源的竞态条件
通过一个特定的例子,可以更容易地理解我所说的。
import { Worker, isMainThread } from 'node:worker_threads';
if (isMainThread) {
new Worker(import.meta.filename);
new Worker(import.meta.filename);
} else {
// 工作线程代码
}
我们使用相同的文件来运行主线程和工作线程。isMainThread
条件下的代码块仅对主线程执行。你可能还会注意到import.meta.filename
,它是自Node 20.11.0以来可用的ES6替代__filename
变量。接下来,我们引入一个共享资源和对共享资源的操作。
import { Worker, isMainThread, workerData, threadId } from 'node:worker_threads';
if (isMainThread) {
const buffer = new SharedArrayBuffer(1);
new Worker(import.meta.filename, { workerData: buffer });
new Worker(import.meta.filename, { workerData: buffer });
} else {
const typedArray = new Int8Array(workerData);
typedArray[0] = threadId;
console.dir({ threadId, value: typedArray[0] });
}
我们将SharedArrayBuffer
作为workerData
传递给每个工作线程。两个工作线程都将其缓冲区的第一个元素更改为其ID。然后,我们记录缓冲区的第一个元素。
其中一个工作线程的ID等于1
,另一个等于2
。不继续阅读,你认为运行此代码时的输出会是什么?
这是结果。
# 第1种结果类型
{ threadId: 1, value: 2 }
{ threadId: 2, value: 2 }
# 第2种结果类型
{ threadId: 1, value: 1 }
{ threadId: 2, value: 1 }
# 第3种结果类型
{ threadId: 1, value: 1 }
{ threadId: 2, value: 2 }
你注意到了吗?为什么我们有线程值相同的情况?如果你从一个单线程程序的角度来考虑,我们应该每次都看到不同的值。
即使我们在单线程中异步运行这段代码,唯一可能不同的是结果打印的顺序,而不是最终值的如此巨大的差异。
这里发生的情况是,一个线程在这两行之间分配值:
typedArray[0] = threadId;
// 一个线程在这里偷偷地分配值
console.dir({ threadId, value: typedArray[0] });
情况是这样的:
- 第一个线程为共享缓冲区分配值
- 第二个线程为共享缓冲区分配值
- 第一个线程将结果打印到控制台
- 第二个线程将结果打印到控制台
正如你所见,当我们有共享资源和多个线程时,即使只有10行代码,也很容易遇到竞态条件。这就是为什么我们需要一种机制,可以确保一个工作线程不会中断另一个工作线程的工作流程。Atomics
API正是为此目的而创建的。
Atomics
我想强调,使用Atomics
是唯一可能的方式,以100%确定你不会在处理多个线程和它们之间的共享资源时遇到竞态条件。
Atomics
的主要目的是确保单个操作作为一个单一的、不可中断的单元执行。换句话说,它确保没有其他工作线程可以在当前可执行操作中间插入并做他们的事情,就像我们之前看到的那样。
让我们使用Atomics
重写带有竞态条件的例子。
import { Worker, isMainThread, workerData, threadId } from 'node:worker_threads';
if (isMainThread) {
const buffer = new SharedArrayBuffer(1);
new Worker(import.meta.filename, { workerData: buffer });
new Worker(import.meta.filename, { workerData: buffer });
} else {
const typedArray = new Int8Array(workerData);
const value = Atomics.store(typedArray, 0, threadId);
console.dir({ threadId, value });
}
我们改变了两件事:我们保存值的方式和我们读取保存的值的方式。使用Atomics
,我们可以使用store
函数同时执行这两个操作。
当你运行这段代码时,你不会看到两个线程具有相同值的情况。它们总是不同的。
[1, 1]
[2, 2]
[2, 2]
[1, 1]
我们可以使用2个操作而不是1个:store
和load
。
const typedArray = new Int8Array(workerData);
Atomics.store(typedArray, 0, threadId);
const value = Atomics.load(typedArray, 0);
console.dir({ threadId, value });
然而,这种方法仍然容易受到竞态条件的影响。使用Atomics
的整个目的是使我们的操作变得原子化。
在这种情况下,我们希望2个操作作为一个单一的原子操作执行:保存一个值并读取这个值。当我们使用store
和load
函数时,我们实际上是在执行2个单独的原子操作,而不是1个。
这就是为什么仍然可能遇到一个工作线程的代码在其他线程的store
和load
调用之间插入的竞态条件。
Atomics
不仅仅是2个函数,在下一篇文章中,我们将介绍如何使用它的更多函数来构建我们自己的信号量和互斥锁,以使共享资源的工作更加方便。
结论
当只有一个线程时,Node.js既有趣又好用。如果你在它上面引入了多个线程和共享资源,你就会得到一个竞态条件不可避免的环境。
在JavaScript中,只有一个机制可以让你减轻这些问题并避免竞态条件,它叫做Atomics
。
Atomics
的理念是执行操作作为一个单一的、不能从外部中断的单元。
由于这样的设计,我们可以确信,每当我们使用Atomics
函数时,其他线程就没有办法进入这样的操作内部。