如何在NodeJS中使用工作线程(附代码示例)

232 阅读12分钟

如何在NodeJS中使用工作线程

解读Node.js中的线程

通常,我们会谈论Node的单线程特性如何使Node应用程序易于扩展和节省资源。但这也使得Node成为实现CPU密集型任务的不合适的选择

但作为解决这个问题的一个办法,在其v10.5.0版本中,Node引入了工作线程的概念。工作线程提供了一种机制,可以催生新的线程来处理Node程序中的CPU密集型任务。

因此,在本教程中,我们将给你一个关于工作线程的简化介绍,以及如何使用它们。然而,为了理解为什么工人线程很重要,让我们先讨论一下究竟为什么Node在处理CPU密集型任务时表现不佳。


为什么Node不能处理CPU密集型任务?

我们把运行在Node程序中的单线程称为事件循环。当一个事件被触发时,Node用它来处理下面的执行。

但是,如果事件的执行是昂贵的,比如在CPU绑定和I/O绑定任务的情况下,Node无法承担在事件循环中运行它而不把唯一可用的线程耽搁很长时间。

因此,事件循环不是等待昂贵的操作完成,而是注册与该事件相关的回调函数,并继续处理循环中的下一个事件。

昂贵的操作被卸载到一组称为工作者池的辅助线程上。工作者池中的线程异步地执行任务,一旦操作完成就通知事件循环。

然后,事件循环在其线程上执行为该操作注册的回调函数。

因为回调是在事件循环上运行的,如果其中任何一个回调包含CPU密集型操作,如机器学习或大数据中使用的复杂数学计算,它就会阻塞事件循环相当长的时间。在此期间,应用程序将不会执行事件循环中的任何其他任务,包括响应来自客户端的请求。

这样的情况大大降低了Node应用程序的性能。因此,在很长一段时间内,Node被认为不适合处理CPU密集型的操作。

但工人线程的引入为这个问题提供了一个解决方法。


工作线程是如何工作的?

在第10版中,Node引入了工作线程,作为一项实验性功能。它在第12版中变得稳定。工作者线程的行为并不完全像传统的多线程系统,因为它不是Javascript本身内置的一个功能。

但它允许将昂贵的任务委托给独立的线程,而不是阻塞应用程序的事件循环。那么,工人线程究竟是如何工作的呢?

工人线程的职责是运行由父线程或主线程指定的一段代码。每个工作者都在与其他工作者隔离的情况下运行。然而,一个工作者和它的父线程可以通过消息通道来回传递消息。

在Javascript不支持多线程的情况下,为了使工作者相互隔离地运行,工作者线程使用了一种特殊的机制。

我们都知道Node是在Chrome的V8引擎之上运行的。V8支持创建隔离的V8运行时。这些孤立的实例,被称为V8 Isolate,有自己的Javascript堆和微任务队列。

工作线程在这些隔离的V8引擎上运行,每个工作者都有自己的V8引擎和事件队列。换句话说,当工作者处于活动状态时,一个Node应用程序有多个Node实例在同一进程中运行。

尽管Javascript本身并不支持并发,但worker线程提供了一种变通方法,可以在一个进程中运行多个线程。


创建和运行新的工作线程

在这个例子中,我们将执行一个计算斐波那契数列第n项的任务。这是一个CPU密集型的任务,如果没有工人线程来执行,会阻塞我们Node应用程序的单线程,特别是当第n项增加时。

我们把我们的实现分成了两个文件。第一个文件,app.js ,是主线程执行的代码,包括创建一个新的工作者,都在其中。第二个文件,worker.js ,包括由我们创建的工作器运行的代码。它是CPU密集型斐波那契计算的代码所在。

让我们看看如何处理在父线程中创建新的工作者:

const {Worker} = require("worker_threads");

let num = 40;

//Create new worker
const worker = new Worker("./worker.js", {workerData: {num: num}});

//Listen for a message from worker
worker.once("message", result => {
  console.log(`${num}th Fibonacci Number: ${result}`);
});

worker.on("error", error => {
  console.log(error);
});

worker.on("exit", exitCode => {
  console.log(exitCode);
})

console.log("Executed in the parent thread");

我们使用Worker类来创建一个新的工作线程。在创建一个新的Worker实例时,它接受以下参数。

new Worker(filename[, options])

这里,文件名参数指的是文件的路径,该文件包含应由工人线程执行的代码。因此,我们需要传递worker.js 文件的文件路径。

Worker构造函数还接受一些选项,你可以参考Worker类的官方文档。但是,我们只选择使用 workerData 选项。通过 workerData 选项传递的数据将在 Worker 开始运行时提供给它。我们可以使用此选项来轻松地将 "n "的值传递给斐波那契数列计算器。

为了在执行结束时接收结果,父线程为 Worker 附加了一些事件监听器。

在这个实现中,我们选择了对三个事件的监听。它们是:

  • 消息。当工作者向父线程发布消息时,该事件会被触发。
  • 错误。如果在运行工作者时发生错误,就会触发该事件。
  • exit。当工作者从执行中退出时,会触发此事件。如果它在调用process.exit() 后退出,则退出代码将被设置为 0。如果执行是通过worker.terminate() 终止的,则代码将为 1。

在消息事件中,工作者使用连接工作者和父类的消息通道来发送消息。我们将在后面的章节中看到消息传递的真正作用。

一旦工作者被创建,父线程就可以继续执行,而无需等待结果。当我们运行上述代码时,在返回第n个斐波那契数列之前,字符串 "在父线程中执行 "被记录到控制台。

因此,我们看到的输出是这样的:

Executed in the parent thread
40th Fibonacci Number: 102334155

现在,让我们在 worker.js 文件中写下 worker 实现的代码:

const {parentPort, workerData} = require("worker_threads");

parentPort.postMessage(getFib(workerData.num))

function getFib(num) {
    if (num === 0) {
      return 0;
    }
    else if (num === 1) {
      return 1;
    }
    else {
      return getFib(num - 1) + getFib(num - 2);
    }
}

在这里,我们使用递归的getFib 函数来计算斐波那契数列的第 n 项。

但更有趣的是,我们通过workerData 选项从父方接收数据的方式。通过这个对象,工作者可以访问创建它时传递的 "num "的值。

然后,我们还使用这个parentPort 对象向父线程发布一条消息,其中包含斐波那契计算的结果。


父线程和工作线程之间的通信

正如你在前面的例子中看到的,parentPort 对象允许工作者与父线程通信。

这个parentPortMessagePort 类的一个实例。当父线程或工作者使用MessagePort实例发送消息时,该消息被写入消息通道,并触发一个 "消息 "事件以通知接收器。

父代可以使用相同的postMessage 方法向工作者发送消息:

worker.postMessage("Message from parent");

只要有需要,我们就可以使用消息通道在父类和工作者之间来回发送多个消息。


处理 Worker 的更好方法

在前面的示例中,我们创建的 Worker 在一次 Fibonacci 计算结束后退出。通过这种实现方式,如果我们想计算另一个斐波那契数,我们就必须生成一个新的工作线程。

但是,用独立的V8引擎和事件循环来生成工作线程,是一项非常耗费资源的任务。特别是,在生产代码中遵循这种方法不会相当有效。

因此,我们可以重用同一个工人来进行每一次计算,而不是为每一次斐波那契计算创建新的工人线程。以下是这种行为的实现方式:

//worker.js
const {parentPort} = require("worker_threads");

parentPort.on("message", data => {
  parentPort.postMessage({num: data.num, fib: getFib(data.num)});
});

function getFib(num) {
  if (num === 0) {
    return 0;
  }
  else if (num === 1) {
    return 1;
  }
  else {
    return getFib(num - 1) + getFib(num - 2);
  }
}

worker.js 中,我们将工作者设置为监听来自其父辈的消息。

父代不是通过workerData选项传递数据,而是选择与消息一起传递数据:

//app.js

const {Worker} = require("worker_threads");

//Create new worker
const worker = new Worker("./worker.js");

//Listen for a message from worker
worker.on("message", result => {
  console.log(`${result.num}th Fibonacci Number: ${result.fib}`);
});

worker.on("error", error => {
  console.log(error);
});

worker.postMessage({num: 40});
worker.postMessage({num: 12});

现在,我们可以使用同一个 Worker 来运行多个 CPU 密集的操作。

值得注意的是,通过postMessage 方法传递的数据,与作为workerData 传递的数据类似,在发送给 Worker 之前,会使用结构化的克隆算法进行克隆。这可以防止两个线程修改同一个对象时出现的竞赛条件。


在父线程和工作线程之间共享内存

节点工作线程允许应用程序在父线程和工作线程之间共享内存,类似于传统的多线程系统。

共享内存比克隆数据并在父线程和工作线程之间来回传递更有效率。

为了在线程之间共享内存,我们使用一个SharedArrayBuffer

像这样在两个线程之间共享内存可能会导致程序中出现竞赛条件。当两个线程试图同时读写同一个内存位置时,就会出现竞赛条件

我们可以使用Atomics来解决这个问题。Atomics确保一个读或写操作完成后再开始下一个操作。它可以防止竞赛条件的发生,因为现在,在一个特定的时间内只有一个线程可以访问共享内存。

让我们看看如何用AtomicsSharedArrayBuffer 来实现内存共享:

const {Worker} = require("worker_threads");

let nums = [21, 33, 15, 40];

//get size of the array buffer with int32 size buffer for each element in the array
const size = Int32Array.BYTES_PER_ELEMENT*nums.length;

//create the buffer for the shared array
const sharedBuffer = new SharedArrayBuffer(size);
const sharedArray = new Int32Array(sharedBuffer);

nums.forEach((num, index) => {
  Atomics.store(sharedArray, index, num);
})

//Create new worker
const worker = new Worker("./worker.js");

//Listen for a message from worker
worker.on("message", result => {
  console.log(`${result.num}th Fibonacci Number: ${result.fib}`);
});

worker.on("error", error => {
  console.log(error);
});

worker.postMessage({nums: sharedArray});

通过这种实现方式,父线程和工作线程都可以从共享数组对象中读取数据并向其写入数据。

工作者执行的代码略有变化,因为现在我们传递的是一个数组,而不是一个单独的数字:

parentPort.on("message", data => {
  data.nums.forEach(num => {
    parentPort.postMessage({num: num, fib: getFib(num)});
  });
})

工作者为其计算的每个斐波那契数字立即向父线程发送一条消息,而无需等待整个过程的完成。

在这个实现中,尽管我们使用 postMessage 方法向 Worker 发送数据,但它在将数据发送给 Worker 之前并没有克隆数据。相反,它传递了一个对共享数组对象的引用。

在Worker Threads之前提供的并行处理解决方案Node,如Cluster和Child Process,并不具备在线程之间共享内存的能力。这使得Worker Threads比这些解决方案更具优势。


线程工作池

正如我们之前所讨论的,创建一个工作者是一项昂贵的任务。这就是为什么我们决定重复使用同一个工作者,而不让它在执行完一个任务后退出。我们可以更进一步,创建一个等待执行任务的工作者池。

当我们只有一个工作者时,应用程序必须等待,直到工作者被释放,才能接受新的任务。这就延迟了应用程序对其他排队请求的响应时间。我们可以通过使用一个线程池来避免这种情况。

然后,池子可以从可用的工作者数量中为我们提供一个不活动的工作者,以无延迟地执行任务。

不幸的是,Worker Threads还没有对工人池的本地支持。因此,我们必须依靠第三方库来使我们的任务更容易完成。我们将使用node-worker-threads-pool npm包。

以下是我们如何使用该包创建一个静态工作者池(即固定数量的工作者):

const {StaticPool} = require("node-worker-threads-pool");

let num = 40;

//Create a static worker pool with 8 workers
const pool = new StaticPool({
  size: 8,
  task: "./worker.js"
});

//Get a worker from the pool and execute the task
pool.exec({num: num}).then(result => {
  console.log(`${result.num}th Fibonacci Number: ${result.fib}`);
});

当我们运行pool.exec() 函数时,该池提供了一个不活动的工作者来运行我们的任务。

工作者的代码不需要经过任何修改来支持工作者池。

const {parentPort} = require("worker_threads");

parentPort.on("message", data => {
  parentPort.postMessage({num: data.num, fib: getFib(data.num)});
})

function getFib(num) {
  if (num === 0) {
    return 0;
  }
  else if (num === 1) {
    return 1;
  }
  else {
    return getFib(num - 1) + getFib(num - 2);
  }
}

总结

在本教程中,我们讨论了如何使用 Node.js Worker Threads 来创建新线程,以运行 CPU 密集型任务。将这些任务卸载到一个单独的线程中,有助于运行我们的Node应用程序,即使在处理CPU密集型任务时也不会出现明显的性能下降。

尽管Node不支持传统方式的多线程,但Worker Threads为这个问题提供了一个很好的解决方法。因此,如果你认为Node应用程序不能有一个以上的线程,现在是时候抛弃这种观念,试试Worker Threads了。