Node.js由于其非阻塞、事件驱动和单线程的特性,是构建实时网络应用的最受欢迎的技术之一。 Node.js的建立是为了开发能够有效处理I/O密集型操作的应用。
为什么我们需要并行处理?
libuv是一个基于C++的库,有助于以非阻塞的方式异步执行基于I/O的操作。 它还提供了一个名为Event Loop的接口,从调用堆栈中获取要执行的代码并执行。 由于单线程的特性,Event Loop一次只执行一件事。
当Event Loop遇到必须异步执行的代码时(例如,访问文件系统、进行网络调用和其他I/O操作),它不会自己执行该代码,而是将其委托给一个线程池,然后继续执行程序的其余部分。 然后,这些线程平行地执行异步代码,并在完成后返回一个事件给Event Loop。
Node.js对I/O操作的这种非阻塞异步行为有助于最大限度地提高吞吐量,并使其成为构建I/O密集型应用程序的理想选择。
然而,没有无刺的玫瑰。注重I/O的架构和Event Loop的单线程性质也意味着Node.js在CPU密集型操作方面的性能不是很好。
与I/O操作不同,基于CPU的操作由Event Loop本身执行,而不是委托给线程池进行异步执行。 因此,对于耗时的CPU操作,Event Loop仍然被占用,程序被阻止进一步执行。
那么,这是否意味着Node.js在开发有大量CPU操作的应用程序时是个大问题?并非如此!
虽然Node.js主要是为I/O密集型应用程序设计的,但它为我们提供了额外的能力,以有效地处理CPU要求的任务。而其中一个方法就是使用工作线程。
工作线程的拯救
工作线程帮助我们将CPU密集型任务从事件循环中卸载出来,以非阻塞的方式并行执行。 工作线程按照父线程的指示运行一段代码,与父线程和其他工作线程隔离。 每个工作线程都有自己隔离的V8环境、事件循环、事件队列等。 工作线程为我们提供了一种在单一进程中运行多个线程的方法。
除了保持事件循环不受耗时的CPU操作影响外,我们还可以使用工作线程池来划分和并行执行繁重的CPU操作,以提高我们的Node.js应用程序的性能。
为了更好地理解Node.js工作线程的好处,让我们以一个简单的CPU密集型任务为例,比较该任务在各种情况下的执行时间。
手头的任务
假设给我们一个目录,里面有1000个JSON文件,我们的任务是将每个JSON文件转换为XML文件。
每个JSON文件有一个1000个对象的数组,每个对象有6个键值对,如下图所示。
{
"id": 1,
"first_name": "abc",
"last_name": "xyz",
"email": "abcxyz@360.cn",
"gender": "Male",
"ip_address": "163.213.59.129"
}
我们的程序将读取每个JSON文件,对其进行解析并将其转换为XML输出。
读/写文件是I/O操作,Node.js将异步处理它们。
然而,解析JSON并将其转换为XML将是一个对CPU要求很高的操作,因为我们有大量的大型JSON对象。
执行场景
注意:在所有的方案中,从JSON文件中读取内容的时间并没有加入到执行时间中。 我们使用了 process.hrtime.bigint()为了平衡误差,我们将每个方案运行5次,取平均时间作为执行时间。
场景1--无工作线程的同步执行
在这里,我们以同步的方式解析和转换JSON内容,不使用工作线程。
/* index.js */
// Read all JSON file contents into an array
const contents = getContents()
/* Execution time start */
const start = hrtime.bigint()
// Convert each JSON file content to XML format
const result = contents.map((content) => {
content = JSON.parse(content)
return js2xmlparser.parse('user', content)
})
/* Execution time end */
const end = hrtime.bigint()
console.info(`Execution time: ${(end - start) / BigInt(10 ** 6)}ms`)
⏰平均执行时间: 1036 ms
场景2 - 使用一个工作线程的异步执行
在这里,我们用一个工作线程以异步的方式解析和转换JSON内容。
我们将使用Node.js中内置的worker_threads模块来管理工作线程
/* index.js */
const { Worker } = require('worker_threads')
// Read all JSON file contents into an array
const contents = getContents()
/* Execution time start */
// Create a new worker
const worker = new Worker('./worker.js')
// Send the contents to the worker
worker.postMessage(contents)
// Get result from the worker
worker.on('message', (result) => {
/* Execution time end */
// `result` has the converted XML
})
/* worker.js */
const { parentPort } = require("worker_threads");
const js2xmlparser = require("js2xmlparser");
// Receive message from the parent
parentPort.on("message", (contents) => {
// Send result back to parent
parentPort.postMessage(
contents.map(content => {
content = JSON.parse(content);
return js2xmlparser.parse("user", content);
});
);
});
⏰ 平均执行时间: 1146 ms
在这种情况下,我们看到执行时间略有增加。 虽然现在对CPU要求很高的操作是在一个单独的线程上运行的,但工作者也会花费类似的时间来执行操作。 创建一个新的工作者线程本身就是一个昂贵的操作,因此我们看到执行时间的增加。
如果你有一些程序的其他部分可以在主线程中并行执行,那么在单个线程上异步执行CPU操作是有好处的。 因为在我们的用例中,我们在转换操作后没有任何指令需要执行,主线程将等待,直到子线程完成其执行。
然而,我们可以将我们的CPU密集型(JSON到XML的转换)操作分为多个部分,并将每个部分分配给一个单独的线程。 我们可以将每个部分分配给单独的工人线程,或者我们可以将一个部分分配给一个工人线程,另一个部分我们可以让主线程自己处理。 无论哪种方式,我们将有一个以上的线程平行工作在操作上。 现在,让我们假设我们想保持主线程为其他任务释放,并采用两个工人线程执行的方式。
尽管我们可以通过创建一个工人线程数组来实现上述用例,但最好还是使用一个工人线程池库,它将为我们简化和抽象出工人线程的管理。
场景3--两个工人线程的并行执行
在这里,我们将JSON解析并转换为两部分。要转换的一半内容(50个JSON文件),我们将分配给一个工作线程,剩下的一半内容我们将分配给另一个工作线程。 为了管理工作线程,我们将使用一个叫做piscina的池库。
/* index.js */
const Piscina = require('piscina')
// Read all JSON file contents into an array
const contents = getContents()
/* Execution time start */
// Divide the content into two chunks
const chunks = splitToChunks(contents, 2)
// Create a new thread pool
const pool = new Piscina()
const options = { filename: resolve(__dirname, 'worker-pool') }
// Run operation on the chunks parallely
const result = await Promise.all([pool.run(chunks[0], options), pool.run(chunks[1], options)])
/* Execution time end */
/* worker-pool.js */
const js2xmlparser = require('js2xmlparser')
module.exports = async (contents) => {
return contents.map((content) => {
content = JSON.parse(content)
return js2xmlparser.parse('user', content)
})
}
⏰平均执行时间: 687 ms
使用两个工作线程,我们可以看到与之前的方案相比,执行时间明显减少。 有一个以上的线程并行处理部分CPU密集型操作,大大增加了我们程序的性能。
接下来,让我们尝试进一步划分上述操作,以便我们可以采用更多数量的线程,看看对性能的影响。
下面你可以看到我使用piscina库记录的不同数量的工作线程的执行时间。
| 工作线程的数量 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|
| 执行时间(以毫秒为单位) | 687 | 577 | 476 | 502 | 527 | 558 | 609 |
从以上数据可以看出,在4个工作线程之前,执行时间一直在减少,之后开始逐渐增加。 更多的线程也意味着更多的时间用于创建线程、线程之间的通信以及上下文切换。
最后说明
采用工作线程来并行处理CPU密集型操作确实能带来相当大的性能改善。然而,这并不意味着更多的线程可以直接对应更好的性能。