最近工作中又有可能需要写 Node.js 应用了,距离上次写 Node.js 应用也有好些年了,所以就开始重新熟悉下 Node.js 了。刚好最近又在学 Go,其最大的特点就是简单、轻量级的并发模型。非常容易用它编写一个能够充分利用硬件资源的高性能应用。于是不免想起以前学习 Node.js 时会遇到的问题:如何让 Node.js 充分利用多核 CPU 的资源。于是,让我发现了,Node.js 从 v10.5.0 开始引入 worker_threads 模块来解决该问题。并让我发现了这篇文章。
此文为译文,原文如下。
译自 Deep Dive into Worker Threads in Node.js
多年来,Node.js 一直都不是实现 CPU 密集型应用的最佳选择。其中最主要的原因就是 Node.js 仅仅是 Javascript 而 JavaScript 是单线程的。作为该问题一个解决方法,Node.js 从 v10.5.0 开始引入了实验性的 Worker Threads 概念,并将其体现在 worker_threads
模块,该模块从 Node.js v12 LTS 开始作为一个稳定功能模块提供出来。在这边文章中,我将会说明它们是如何工作的,怎样使用 worker threads 才能获取最好的性能。假如你对 Node.js worker threads 还不了解的话,我建议你查看它们的官方文档 。
注:文中引用的 Node.js 代码片段版本为 921493e 。
在 worker threads 出现前,就已经有很多种方案来完成基于 Node.js 的 CPU 密集型应用。常见的有如下几种:
child_process
模块,在子进程中运行耗费 CPU 的代码操作。cluster
模块,在多个进程中运行耗费 CPU 资源的代码操作。但是,由于性能局限、额外的引入学习成本、接受度的不足、不稳定性以及文档缺失等原因,这其中没
一个方案是能被广泛接受的。
尽管 worker_threads
对于 JavaScript 的并发问题来说是一个优雅的解决方案,但是其实 JavaScript 本身并没有引进将多线程的语言特性。实际上,worker_threads
是通过允许应用可以运行多个独立 JavaScript workers,workers 和 其父 workers 可以通过 Node.js 来通信。听起来很困惑?
在 Node.js 中,每个 worker 有他自己的 V8 实例和事件循环机制(Event Loop)。但是,和子进程不同,workers 是可以共享内存的。
在此文的后面我会解释它们是如何能够拥有独立的 V8 实例和事件循环(Event Loop)的。不过我们先来看看我们能如何使用 worker threads。下面是一个基本用法的示例: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)
}
在上面的例子中,我们将一个数字传给另一个 worker 来计算其平方值。在完成计算后,该 child worker
会将其结果发送给 main worker。尽管这看起来很简单,但是如果你不了解 Node.js 的 worker threads
的话,还是会觉得有些困惑的。
JavaScript 并没有多线程的特性,所以 Node.js 的 Worker Threads 和其他支持多线程的高级语言在处理上是不一样的。
在 Node.js 中,一个 worker 的职责就是执行 parent worker 提供给他的代码片段(worker script)。这个 worker script 可以是一个单独的文件,也可以是一个能够被 eval
代码文本;它将被放在别的 worker 中独立运行,且 该 worker 和其 parent worker 之间是可以传递消息的。在我们的例子中,我们提供了 __filename
作为这个代码片段,那是因为 worker 和 parent worker 的执行代码是一样的,只是在其中通过 isMainThread
来区分了。
每一个 worker 都通过一个 message channel 来和其 parent worker 通信。child worker 可以通过 parentPort.postMessage
将信息写入信道,而 parent worker 需要通过 worker.postMessage()
来将消息写入信道。看看下图(图一)他们是如何工作的
一个信道就是一个简单的通信渠道。它有两个端口,被叫做 ‘ports’。在 JavaScript / NodeJS 中,信道的两端就只是被简单的叫做‘port1’ 和 ‘port2’……
现在,最大的问题就是,,既然 JavaScript 本身并不支持并行,那么两个 Node.js workers 是如何并行执行的呢?
答案就是 V8 Isolates 。
V8 Isolates 是一个独立的 chrome V8 运行实例,其有独立的 JS 堆和微任务队列。这就为每一个Node.js worker 独立运行提供了保障。其缺陷就是,workers 之间没法直接访问对方的堆。由于这个原因,每个 worker 都有其自己的 libuv event loop。
Worker 的实例化和通信是通过 C++ 的 worker 来实现的。该模块的实现在 worker.cc 。
Worker 的实现通过 worker_threads
模块暴露在了 JavaScript 的用户空间。这个 JS 的实现被分成了两个 scripts :
workerData
和 parent worker 提供的 metadata 来执行用户的 JS 脚本。下图(图二)提供了一个更清晰的说明。看看该图描述了些什么
按照上面说的,我们可以把 worker 的设置进程分成两步。
让我们看卡这两步都干了啥。
worker_threads
模块创建一个 worker 实例IMC
)。就是上面图二的 “Initialisation Message Channel”PMC
)。该信道是给用户空间的 JS 使用的,便于他们在 parent worker 和 child worker 之间通过 *.postMessage()
方法来传递消息。就是上面图一和图二的红色部分初始化 metadata 是什么? worker 执行脚本启动时需要知道的数据,eg. 脚本名称、worker 数据、 PMC 的
port2
以及一些其他信息。
根据我们的例子来说,初始化 metadata 就是类似如下的一条信息:
嗨,Worker 执行脚本,使用 worker data{num: 5}
来运行worker-simple.js
。并将 PMC 的port2
传递给它,以便它能从 PMC 读取或写入消息。
下面这个代码片段展示了初始化 metadata 是如何被写入到 IMC 的。
const kPublicPort = Symbol('kPublicPort');
// ...redacted...
const {
port1, port2 } = new MessageChannel();
this[kPublicPort] = port1;
this[kPublicPort].on('message', (message) => this.emit('message', message));
// ...redacted...
this[kPort].postMessage({
type: 'loadScript',
filename,
doEval: !!options.eval,
cwdCounter: cwdCounter || workerIo.sharedCwdCounter,
workerData: options.workerData,
publicPort: port2,
// ...redacted...
hasStdin: !!options.stdin
}, [port2]);
在上面的片段中,this[kPort]
就是 IMC 的 end port。尽管此时已经向 IMC 写数据了,但是 worker 执行脚本还是无法获取这些数据,因为它还没有被启动呢。
此时,初始化阶段完成。然后,就会调用 C++ 来启动这个 worker 线程。
worker-simple.js
下面这个代码片段展示了 worker 是如何执行脚本的。
const publicWorker = require('worker_threads');
// ...redacted...
port.on('message', (message) => {
if (message.type === 'loadScript') {
const {
cwdCounter,
filename,
doEval,
workerData,
publicPort,
manifestSrc,
manifestURL,
hasStdin
} = message;
// ...redacted...
initializeCJSLoader();
initializeESMLoader();
publicWorker.parentPort = publicPort;
publicWorker.workerData = workerData;
// ...redacted...
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();
}
}
// ...redacted...
在上面的代码片段中,你是否有发现 workerData
和 parentPort
属性是在 require('worker_threads')
中被 worker 执行脚本设置的?
这就是为什么 workerData
和 parentPort
属性只能在 child worker thread 的代码而不是 parent worker thread 的代码中被访问了。假如你尝试在 parent worker 的代码中获取这些属性,你只能得到 null
。
现在我们了解了 Node.js Worker Threads 是怎么工作的了。了解他们的原来能让我们更好的使用 worker threads 来获取最佳的性能。当编写复杂的应用的时候(比 worker-simple.js
复杂的多),我们需要牢记下面两个点
为了克服上述的第一个问题吗,我们需要实现 “Worker 线程池”。
通过使用线程池技术,使得当新任务来临时,我们可以通过 parent-child 信道将 其传递给一个可用的 worker 来执行它,一旦这个 worker 完成了该 task,其可以用过同样的信道来将其执行结果传递过来。一旦正确的实现线程池,那么其将显著的提升应用性能。因为它能够减少额外的创建 threads 消耗。同样值得一提的是,创建大量线程并不一定就是特别高效了,因为其还受制于硬件的资源。
下图是三种情况的一个性能比较,其实现了相同的功能:接受一个 string 然后给他加密并返回。三种不同的服务如下:
然而,Node.js 官方目前并没有提供线程池的使用。所以你需要依赖第三方的实现或者自己实现一个。这是我自己实现的一个 worker pool 。它还不能被用于生产环境