在Node.js中使用工作线程进行并行处理的方法

614 阅读7分钟

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库记录的不同数量的工作线程的执行时间。

工作线程的数量2345678
执行时间(以毫秒为单位)687577476502527558609

从以上数据可以看出,在4个工作线程之前,执行时间一直在减少,之后开始逐渐增加。 更多的线程也意味着更多的时间用于创建线程、线程之间的通信以及上下文切换。

最后说明

采用工作线程来并行处理CPU密集型操作确实能带来相当大的性能改善。然而,这并不意味着更多的线程可以直接对应更好的性能。