原文:blog.insiderattack.net/deep-dive-i…javascript
多年以来,Node.js 都不是实现高 CPU 密集型应用的最佳选择,这主要就是由于 JavaScript 的单线程。做为对此问题的解决方案,Node.js v10.5.0 经过 worker_threads
模块引入了实验性的 “worker 线程” 概念,并从 Node.js v12 LTS 起成为一个稳定功能。本文将解释其如何工做,以及如何使用 Worker 线程得到最佳性能。前端
在 worker 线程以前,Node.js 中有多种方式执行 CPU 密集型应用。其中的一些为:java
child_process
模块并在一个子进程中运行 CPU 密集型代码cluster
模块,在多个进程中运行多个 CPU 密集型操做Napa.js
这样的第三方模块可是受限于性能、额外引入的复杂性、占有率低、薄弱的文档化等,这些解决方案无一被普遍采用。node
尽管对于 JavaScript 的并发性问题来讲,worker_threads
是一个优雅的解决方案,但其并未给 JavaScript 自己带来多线程特性。相反,worker_threads
经过运行应用使用多个相互隔离的 JavaScript workers 来实现并发,而 workers 和父 worker 之间的通讯由 Node 提供。听懵了吗? 🤷♂️git
在 Node.js 中,每个 worker 将拥有其本身的 V8 实例及事件循环(Event Loop)。但和 child_process
不一样的是,workers 不共享内存。github
以上概念会在后面解释。咱们首先来大体看一眼如何使用 Worker 线程。一个原生的用例看起来是这样的:chrome
// worker-simple.js
const {Worker, isMainThread, parentPort, workerData} = require('worker_threads');
if (isMainThread) {
const worker = new Worker(__filename, {workerData: {num: 5}});
worker.once('message', (result) => {
console.log('square of 5 is :', result);
})
} else {
parentPort.postMessage(workerData.num * workerData.num)
}
复制代码
在上例中,咱们向每一个单独的 workder 中传入了一个数字以计算其平方值。在计算以后,子 worker 将结果发送回主 worker 线程。尽管看上去简单,但 Node.js 新手可能仍是会有点困惑。浏览器
JavaScript 语言没有多线程特性。所以,Node.js 的 Worker 线程以一种异于许多其它高级语言传统多线程的方式行事。服务器
在 Node.js 中,一个 worker 的职责就是去执行一段父 worker 提供的代码(worker 脚本)。这段 worker 脚本将会在隔绝于其它 workers 的环境中运行,并可以在其自身和父 worker 间传递消息。worker 脚本既能够是一个独立的文件,也能够是一段可被 eval
解析的文本格式的脚本。在咱们的例子中,咱们将 __filename
做为 worker 脚本,由于父 worker 和子 worker 代码都在同一个脚本文件中,由 isMainThread
属性决定其角色。多线程
每一个 worker 经过 message channel
链接到其父 worker。子 worker 可使用 parentPort.postMessage()
函数向消息通道中写入信息,父 worker 则经过调用 worker 实例上的 worker.postMessage()
函数向消息通道中写入信息。看一下图 1:
一个 Message Channel 就是一个简单的通讯渠道,其两端被称做 ‘ports’。在 JavaScript/NodeJS 术语中,一个 Message Channel 的两端就被叫作
port1
和port2
如今关键的问题来了,JavaScript 并不直接提供并发,那么两个 Node.js workers 要如何并行呢?答案就是 V8 isolate。
一个 V8 isolate 就是 chrome V8 runtime 的一个单独实例,包含自有的 JS 堆和一个微任务队列。这容许了每一个 Node.js worker 彻底隔离于其它 workers 地运行其 JavaScript 代码。其缺点在于 worker 没法直接访问其它 workers 的堆数据了。
扩展阅读:JS在浏览器和Node下是如何工做的?
由此,每一个 worker 将拥有其本身的一份独立于父 worker 和其它 workers 的 libuv 事件循环的拷贝。
实例化一个新 worker、提供和父级/同级 JS 脚本的通讯,都是由 C++ 实现版本的 worker 完成的。在成文时,该实现为worker.cc
(github.com/nodejs/node…)。
Worker 的实现经过 worker_threads
模块被暴露为用户级的 JavaScript 脚本。该 JS 实现被分割为两个脚本,我将之称为:
workerData
数据和其它父 worker 提供的元数据执行用户的 worker JS 脚本。(github.com/nodejs/node…)图 2 以更清晰的方式解释了这个过程:
基于上述,咱们能够将 worker 设置过程划分为两个阶段:
来看看每一个阶段都发生了什么吧:
worker_threads
建立一个 worker 实例IMC
)被父 worker 建立。图 2 中灰色的 “Initialisation Message Channel” 部分展现了这点PMC
)被 worker 初始化脚本建立。 该通道被用户级 JS 使用以在父子 worker 之间传递消息。图 1 中主要描述了这部分,也在图 2 中被标为了红色。IMC
。什么是初始元数据? 即执行脚本须要了解以启动 worker 的数据,包括脚本名称、worker 数据、PMC 的
port2
,以及其它一些信息。按咱们的例子来讲,初始化元数据如:
☎️ 嘿!worker 执行脚本,请你用
{num: 5}
这样的 worker 数据运行一下worker-simple.js
好吗?也请你把 PMC 的port2
传递给它,这样 worker 就能从 PMC 读取数据啦。
下面的小片断展现了初始化数据如何被写入 IMC:
const kPublicPort = Symbol('kPublicPort');
// ...
const { port1, port2 } = new MessageChannel();
this[kPublicPort] = port1;
this[kPublicPort].on('message', (message) => this.emit('message', message));
// ...
this[kPort].postMessage({
type: 'loadScript',
filename,
doEval: !!options.eval,
cwdCounter: cwdCounter || workerIo.sharedCwdCounter,
workerData: options.workerData,
publicPort: port2,
// ...
hasStdin: !!options.stdin
}, [port2]);
复制代码
代码中的 this[kPort]
是初始化脚本中 IMC 的端点。尽管 worker 初始化脚本向 IMC 写入了数据,但 worker 执行脚本仍没法访问该数据。
此时,初始化已告一段落;接下来 worker 初始化脚本调用 C++ 并启动 worker 线程。
worker-simple.js
),以做为一个 worker 开始运行。看看下面的代码片断,worker 执行脚本是如何从 IMC 读取数据的:
const publicWorker = require('worker_threads');
// ...
port.on('message', (message) => {
if (message.type === 'loadScript') {
const {
cwdCounter,
filename,
doEval,
workerData,
publicPort,
manifestSrc,
manifestURL,
hasStdin
} = message;
// ...
initializeCJSLoader();
initializeESMLoader();
publicWorker.parentPort = publicPort;
publicWorker.workerData = workerData;
// ...
port.unref();
port.postMessage({ type: UP_AND_RUNNING });
if (doEval) {
const { evalScript } = require('internal/process/execution');
evalScript('[worker eval]', filename);
} else {
process.argv[1] = filename; // script filename
require('module').runMain();
}
}
// ...
复制代码
是否注意到以上片断中的 workerData
和 parentPort
属性被指定给了 publicWorker
对象呢?后者是在 worker 执行脚本中由 require('worker_threads')
引入的。
这就是为什么 workerData
和 parentPort
属性只在子 worker 线程内部可用,而在父 worker 的代码中不可用了。
若是尝试在父 worker 代码中访问这两个属性,都会返回 null
。
如今咱们理解 Node.js 的 worker 线程是如何工做的了,这的确能帮助咱们在使用 Worker 线程时得到最佳性能。当编写比 worker-simple.js
更复杂的应用时,须要记住如下两个主要的关注点:
为了克服第 1 点的问题,咱们须要实现“worker 线程池”。
Node.js 的 worker 线程池是一组正在运行且可以被后续任务利用的 worker 线程。当一个新任务到来时,它能够经过父子消息通道被传递给一个可用的 worker。一旦完成了这个任务,子 worker 能将结果经过一样的消息通道回传给父 worker。
一旦实现得当,因为减小了建立新线程带来的额外开销,线程池能够显著改善性能。一样值得一提的是,由于可被有效运行的并行线程数老是受限于硬件,建立一堆数目巨大的线程一样难以奏效。
下图是对三台 Node.js 服务器的一个性能比较,它们都接收一个字符串并返回作了 12 轮加盐处理的一个 Bcrypt 哈希值。三台服务器分别是:
一眼就能看出,随着负载增加,使用一个线程池拥有显著小的开销。
可是,截止成文之时,线程池仍不是 Node.js 开箱即用的原生功能。所以,你还得依赖第三方实现或编写本身的 worker 池。
但愿你如今能深刻理解了 worker 线程如何工做,并能开始体验并利用 worker 线程编写你的 CPU 密集型应用。
查看更多前端好文
请搜索 fewelife 关注公众号
转载请注明出处