Node.js 开发人员已经习惯了使用单线程执行 JavaScript。即使通过 引入多线程worker_threads
,您仍会感到相当安全。
然而,当你为多个线程添加共享资源时,情况就不同了。事实上,这是整个软件工程中最具挑战性的主题之一。我说的是多线程编程。
值得庆幸的是,JavaScript 提供了一种内置抽象来缓解跨多线程共享资源的问题。这种机制称为Atomics。
在本文中,您将了解 Node.js 中的共享资源是什么样的,以及Atomics
API 如何帮助我们防止野蛮竞争条件。
多个线程之间共享内存
让我们首先了解什么是可转移对象。
可转移对象是可以从一个执行上下文转移到另一个执行上下文而无需保留原始上下文的资源的对象。
执行上下文是可以执行 JavaScript 代码的地方。为了便于理解,我们假设执行上下文等于工作线程,因为每个线程确实是单独的执行上下文。
编辑
例如,是一个可传输对象。它由两部分组成:原始分配的内存和指向该内存的 JavaScript 句柄。您可以阅读有关JavaScript 中的缓冲区ArrayBuffer
的文章以了解有关此主题的更多信息。
每当我们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 {
// worker code
}
我们使用同一个文件来运行主线程和工作线程。 条件下的块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
给每个 worker workerData
。两个 worker 都将缓冲区的第一个元素更改为其 ID。然后,我们记录第一个缓冲区元素。
其中一个工作者的 ID 等于 ,1
另一个工作者的 ID 等于2
。无需进一步阅读,当此代码运行时,您期望在输出中看到什么?
结果如下。
复制
复制
# 1 type of results
{ threadId: 1, value: 2 }
{ threadId: 2: value: 2 }
# 2 type of results
{ threadId: 1, value: 1 }
{ threadId: 2: value: 1 }
# 3 type of results
{ threadId: 1, value: 1 }
{ threadId: 2: value: 2 }
你注意到了吗?为什么会出现两个线程的值相同的情况?如果你从单线程程序的角度考虑,我们应该看到每次打印的值都不同。
即使我们在单个线程中异步运行此代码,唯一可能不同的是打印结果的顺序,但最终值的差异不会如此之大。
这里发生的情况是其中一个线程在这两行之间分配值:
复制
复制
typedArray[0] = threadId;
// one of the threads sneaks right in here and assign value
console.dir({ threadId, value: typedArray[0] });
具体如下:
- 第一个线程为共享缓冲区分配一个值
- 第二个线程为共享缓冲区赋值
- 第一个线程将结果打印到控制台
- 第二个线程将结果打印到控制台。
如您所见,当我们拥有共享资源和多个线程时,仅 10 行代码就很容易陷入竞争条件。这就是为什么我们需要一种机制来确保一个工作进程不会中断另一个工作进程的工作流程。该Atomics
API 正是为此目的而创建的。
原子 www.cqzlsb.com
我想强调的是,使用是100% 确保在处理多个线程及其之间的共享资源时不会遇到竞争条件的唯一可能方法Atomics
。
的主要目的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
其他线程调用。
不仅仅只有 2 个函数Atomics
,在下一篇文章中,我们将介绍如何使用更多函数来构建我们自己的信号量和互斥锁,以使共享资源的工作更加方便。
结论
当只有一个线程时,Node.js 非常有趣和好用。如果在其上引入多个线程和共享资源,则会不可避免地出现竞争条件。
JavaScript 中只有一种机制可以让你缓解这些问题并避免竞争条件,它被称为Atomics
。
其理念Atomics
是让操作作为一个单独的单元执行,并且不会被外部打断。
由于这样的设计,我们可以确保无论何时使用Atomics
函数,其他线程都无法进入此类操作的内部。