深入理解Node.js中的Worker线程

概述

多年以来,Node.js都不是实现高 CPU 密集型应用的最佳选择,这主要就是因为JavaScript的单线程。作为对此问题的解决方案,Node.jsv10.5.0 通过worker_threads模块引入了实验性的 “worker 线程” 概念,并从 Node.js v12 LTS 起成为一个稳定功能。本文将解释其如何工作,以及如何使用 Worker 线程获得最佳性能。

Node.js 中 CPU 密集型应用的历史

在 worker 线程之前,Node.js 中有多种方式执行 CPU 密集型应用。其中的一些为:

  • 使用child_process模块并在一个子进程中运行 CPU 密集型代码
  • 使用cluster模块,在多个进程中运行多个 CPU 密集型操作
  • 使用诸如 Microsoft 的Napa.js这样的第三方模块

但是受限于性能、额外引入的复杂性、占有率低、薄弱的文档化等,这些解决方案无一被广泛采用。

为 CPU 密集型操作使用 worker 线程

尽管对于JavaScript的并发性问题来说,worker_threads是一个优雅的解决方案,但其并未给 JavaScript 本身带来多线程特性。相反,worker_threads通过运行应用使用多个相互隔离的 JavaScript workers 来实现并发,而 workers 和父 worker 之间的通信由 Node 提供。听懵了吗? ‍♂️

在 Node.js 中,每一个 worker 将拥有其自己的 V8 实例及事件循环(Event Loop)。但和child_process不同的是,workers 不共享内存。

以上概念会在后面解释。我们首先来大致看一眼如何使用 Worker 线程。一个原生的用例看起来是这样的:

// 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 新手可能还是会有点困惑。

Worker 线程是如何工作的?

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

Node.js 的 workers 是如何并行的?

现在关键的问题来了,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 事件循环的拷贝。

跨越 JS/C++ 的边界

实例化一个新 worker、提供和父级/同级 JS 脚本的通信,都是由 C++ 实现版本的 worker 完成的。在成文时,该实现为worker.cc(https://github.com/nodejs/node/blob/921493e228/src/node_worker.cc)。

Worker 的实现通过worker_threads模块被暴露为用户级的 JavaScript 脚本。该 JS 实现被分割为两个脚本,我将之称为:

  • 初始化脚本 worker.js— 负责初始化 worker 实例,并建立初次父子 worker 通信,以确保从父 worker 传递 worker 元数据至子 worker。(https://github.com/nodejs/node/blob/921493e228/lib/internal/worker.js)
  • 执行脚本 worker_thread.js— 根据用户提供的workerData数据和其它父 worker 提供的元数据执行用户的 worker JS 脚本。(https://github.com/nodejs/node/blob/921493e228/lib/internal/main/worker_thread.js)

图 2 以更清晰的方式解释了这个过程:

基于上述,我们可以将 worker 设置过程划分为两个阶段:

  • worker 初始化
  • 运行 worker

来看看每个阶段都发生了什么吧:

初始化步骤

1.用户级脚本通过使用worker_threads创建一个 worker 实例

2.Node 的父 worker 初始化脚本调用 C++ 并创建一个空的 worker 对象。此时,被创建的 worker 还只是个未被启动的简单的 C++ 对象

3.当 C++ worker 对象被创建后,其生成一个线程 ID 并赋值给自身

4.同时,一个空的初始化消息通道(让我们称之为IMC)被父 worker 创建。图 2 中灰色的 “Initialisation Message Channel” 部分展示了这点

5.一个公开的 JS 消息通道(称其为PMC)被 worker 初始化脚本创建。该通道被用户级 JS 使用以在父子 worker 之间传递消息。图 1 中主要描述了这部分,也在图 2 中被标为了红色。

6.Node 父 worker 初始化脚本调用 C++ 并将需要被发送到 worker 执行脚本中的初始元数据写入IMC。

什么是初始元数据?即执行脚本需要了解以启动 worker 的数据,包括脚本名称、worker 数据、PMC 的port2,以及其它一些信息。

按我们的例子来说,初始化元数据如:

:phone: 嘿!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 线程。

1.一个新的V8 isolate被创建并被分配给 worker。前面讲过,一个 “v8 isolate” 就是 chrome V8 runtime 的一个单独实例。这使得 worker 线程的执行上下文隔离于应用代码中的其它部分。

2.libuv被初始化。这确保了 worker 线程保有其自己独立于应用中的其它部分事件循环。

3.worker 执行脚本被执行,并且 worker 的事件循环被启动。

4.worker 执行脚本调用 C++ 并从 IMC 中读取初始化元数据。

5.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。

充分利用 worker 线程

现在我们理解 Node.js 的 worker 线程是如何工作的了,这的确能帮助我们在使用 Worker 线程时获得最佳性能。当编写比worker-simple.js更复杂的应用时,需要记住以下两个主要的关注点:

尽管 worker 线程比真正的进程更轻量,但如果频繁让 workers 陷入某些繁重的工作仍会开销巨大。

使用 worker 线程承担并行 I/O 操作仍是不划算的,因为 Node.js 原生的 I/O 机制是比从头启动一个 worker 线程去做同样的事更快的方式。

为了克服第 1 点的问题,我们需要实现“worker 线程池”。

worker 线程池

Node.js 的 worker 线程池是一组正在运行且能够被后续任务利用的 worker 线程。当一个新任务到来时,它可以通过父子消息通道被传递给一个可用的 worker。一旦完成了这个任务,子 worker 能将结果通过同样的消息通道回传给父 worker。

一旦实现得当,由于减少了创建新线程带来的额外开销,线程池可以显著改善性能。同样值得一提的是,因为可被有效运行的并行线程数总是受限于硬件,创建一堆数目巨大的线程同样难以奏效。

下图是对三台 Node.js 服务器的一个性能比较,它们都接收一个字符串并返回做了 12 轮加盐处理的一个 Bcrypt 哈希值。三台服务器分别是:

  • 不用多线程
  • 多线程,没有线程池
  • 有 4 个线程的线程池

一眼就能看出,随着负载增长,使用一个线程池拥有显著小的开销。

但是,截止成文之时,线程池仍不是 Node.js 开箱即用的原生功能。因此,你还得依赖第三方实现或编写自己的 worker 池。

希望你现在能深入理解了 worker 线程如何工作,并能开始体验并利用 worker 线程编写你的 CPU 密集型应用。

以上就是深入理解Node.js中的Worker线程的详细内容,更多关于Node.js的资料请关注我们其它相关文章!

(0)

相关推荐

  • Nodejs探秘之深入理解单线程实现高并发原理

    前言 从Node.js进入我们的视野时,我们所知道的它就由这些关键字组成 事件驱动.非阻塞I/O.高效.轻量,它在官网中也是这么描述自己的. Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient. 于是在我们刚接触Node

  • 详解node单线程实现高并发原理与node异步I/O

    一.node单线程实现高并发原理 众所周知nodejs是单线程且支持高并发的脚本语言.可为什么单线程的nodejs可以支持高并发呢?很多人都不明白其原理,下面我来谈谈我的理解: 1. node的优点:I/O密集型处理是node的强项,因为node的I/O请求都是异步的(如:sql查询请求.文件流操作操作请求.http请求...) a. 什么是异步? 异步:发出操作指令,然后就可以去做别的事情了,所有操作完成后再执行回调 异步的实现原理: // 第一步:定义变量 let a = 1; // 第二步

  • 使用cluster 将自己的Node服务器扩展为多线程服务器

    用nodejs的朋友都有了解,node是单线程的,也就是说跑在8核CPU上,只能使用一个核的算力. 单线程一直是node的一个诟病,但随着0.6版本中引入cluster之后,这个情况则得到了改变,开发人员可以依靠cluster很轻松的将自己的Node服务器扩展为多线程服务器了. 什么是Cluster cluster是node提供的一个多线程库,用户可以使用它来创建多个线程,线程之间共享一个监听端口,当有外部请求这个端口时,cluster会将请求转发到随机线程里.因为每个node线程都会占用几十兆

  • Node.js事件循环(Event Loop)和线程池详解

    Node的"事件循环"(Event Loop)是它能够处理大并发.高吞吐量的核心.这是最神奇的地方,据此Node.js基本上可以理解成"单线程",同时还允许在后台处理任意的操作.这篇文章将阐明事件循环是如何工作的,你也可以感受到它的神奇. 事件驱动编程 理解事件循环,首先要理解事件驱动编程(Event Driven Programming).它出现在1960年.如今,事件驱动编程在UI编程中大量使用.JavaScript的一个主要用途是与DOM交互,所以使用基于事件

  • 深入浅析Node.js单线程模型

    Node.js采用 事件驱动 和 异步I/O 的方式,实现了一个单线程.高并发的运行时环境,而单线程就意味着同一时间只能做一件事,那么Node.js如何利用单线程来实现高并发和异步I/O?本文将围绕这个问题来探讨Node.js的单线程模型: 1.高并发 一般来说,高并发的解决方案就是多线程模型,服务器为每个客户端请求分配一个线程,使用同步I/O,系统通过线程切换来弥补同步I/O调用的时间开销,比如Apache就是这种策略,由于I/O一般都是耗时操作,因此这种策略很难实现高性能,但非常简单,可以实

  • nodejs中使用worker_threads来创建新的线程的方法

    简介 之前的文章中提到了,nodejs中有两种线程,一种是event loop用来相应用户的请求和处理各种callback.另一种就是worker pool用来处理各种耗时操作. nodejs的官网提到了一个能够使用nodejs本地woker pool的lib叫做webworker-threads. 可惜的是webworker-threads的最后一次更新还是在2年前,而在最新的nodejs 12中,根本无法使用. 而webworker-threads的作者则推荐了一个新的lib叫做web-wo

  • Node.js 多线程完全指南总结

    很多人都想知道单线程的 Node.js怎么能与多线程后端竞争.考虑到其所谓的单线程特性,许多大公司选择 Node 作为其后端似乎违反直觉.要想知道原因,必须理解其单线程的真正含义. JavaScript 的设计非常适合在网上做比较简单的事情,比如验证表单,或者说创建彩虹色的鼠标轨迹. 在2009年,Node.js的创始人 Ryan Dahl使开发人员可以用该语言编写后端代码. 通常支持多线程的后端语言具有各种机制,用于在线程和其他面向线程的功能之间同步数据.要向 JavaScript 添加对此类

  • nodejs中使用多线程编程的方法实例

    在以前的博文别说不可能,nodejs中实现sleep中,我向大家介绍了nodejs addon的用法.今天的主题还是addon,继续挖掘c/c++的能力,弥补nodejs的弱点. 我曾多次提到过nodejs的性能问题.其实就语言本身而言,nodejs的性能还是很高的,虽然不及大多部静态语言,但差距也并不大:相对其他动态语言而言,速度优势非常明显.但为什么我们常常说nodejs不能胜任CPU密集型场景呢?因为由于其单线程特性,对于CPU密集型场景,它并不能充分利用CPU.计算机科学中有一个著名的A

  • 深入理解Node.js中的Worker线程

    概述 多年以来,Node.js都不是实现高 CPU 密集型应用的最佳选择,这主要就是因为JavaScript的单线程.作为对此问题的解决方案,Node.jsv10.5.0 通过worker_threads模块引入了实验性的 "worker 线程" 概念,并从 Node.js v12 LTS 起成为一个稳定功能.本文将解释其如何工作,以及如何使用 Worker 线程获得最佳性能. Node.js 中 CPU 密集型应用的历史 在 worker 线程之前,Node.js 中有多种方式执行

  • 深入理解Node.js中的进程管理

    前言 本文主要对 Node.js 中进程管理相关的东西做一个简单介绍,包括 process 对象.child_process 模块和cluster 模块,详细的 API 可以查看官方文档,下面来看看详细的介绍吧. Process 对象 process 是 Node.js 的一个全局对象,可以在任何地方直接使用而不需要 require 命令加载.process 对象提供了 当前 node 进程 的命令行参数.标准输入输出.运行环境和运行状态等信息. 常用属性 argv process.argv 属

  • 深入理解Node.js中通用基础设计模式

    谈到设计模式,你可能会想到 singletons, observers(观察者) 或 factories(工厂方法).本文不并专门探讨他们.只是探讨Node.JS一些基础模式的实现,像依赖注入或中间件. 什么是设计模式? 设计模式是用来解决一般的,普遍发生的问题,且可重复使用的解决方案. Singletons (单例) Singletons模式限制了"类",只有一个实例.在Node.js的创建单例是非常简单的,比如下面这个require. //area.js var PI = Math

  • 总结Node.js中的一些错误类型

    前言 Node.js应用中可能会发生多种类型的错误.如:当出现语法错误或运行时错误时,会触发JavaScript错误:当试图试访问一个不存在或没有访问的文件时,会触发系统错误:除JavaScript错误和系统错误错误外,用户还可以自定义错误. 1. 错误分类 Node.js是一个JavaScript运行时平台,其应用发生错误都是一个Error实例或Error子类的实例. 在Node.js应用中,可能发生的错误有以下4类: 1.标准JavaScript错误,如: <EvalError> : 当调

  • node.js中对Event Loop事件循环的理解与应用实例分析

    本文实例讲述了node.js中对Event Loop事件循环的理解与应用.分享给大家供大家参考,具体如下: javascript是单线程的,所以任务的执行都需要排队,任务分为两种,一种是同步任务,一种是异步任务. 同步任务是进入主线程上排队执行的任务,上一个任务执行完了,下一个任务才会执行. 异步任务是不进入主线程,而是进入一个 "任务队列" 里,"任务队列" 通知主线程,该异步任务才会进入主线程执行. 任务的运行机制如下: 1.所有同步任务在主线程上执行,形成一个

  • node.js中module模块的功能理解与用法实例分析

    本文实例讲述了node.js中module模块的功能理解与用法.分享给大家供大家参考,具体如下: node.js中使用CommonJS规范实现模块功能,一个单独的文件就是一个单独的模块.通过require方法实现模块间的依赖管理. 通过require加载模块,是同步操作. 加载流程如下: 1.找到需要加载的模块文件. 2.判断是否缓存过,如果没有,则读取模块文件的内容. 3.把读取到的内容,封装在一个函数里运行. (function (exports, require, module, __fi

  • 实例分析JS与Node.js中的事件循环

    这两天跟同事同事讨论遇到的一个问题,js中的event loop,引出了chrome与node中运行具有setTimeout和Promise的程序时候执行结果不一样的问题,从而引出了Nodejs的event loop机制,记录一下,感觉还是蛮有收获的 console.log(1) setTimeout(function() { new Promise(function(resolve, reject) { console.log(2) resolve() }) .then(() => { con

  • Node.js中的async 和 await 关键字微任务和宏任务

    目录 async 和 await 关键字 async 关键字 await关键字 async 和 await 解决回调地狱 JS执行机制(事件循环) 微任务和宏任务 宏任务 微任务 宏任务和微任务执行机制 async 和 await 关键字 async 和 await 是 ES2017 中提出来的,async 和 await 两个关键字的出现,简化的 Promise 的使用. async 关键字 async关键字使用比较简单,所以 async 的使用注意以下三点即可 : async 用于修饰一个

  • Node.js中Process.nextTick()和Process.setImmediate()的区别

    一.Webstrom使用node.jsIDE的问题 在区别这两个函数之前来说一下Webstrom使用node.jsIDE的问题,在配置Node.js的IDE了,但setImmediate().require等这些node.js中内置的函数时并不会有提示,要解决这个问题只要设置一下Webstrom,File---->Setting---->Languages&Frameworks------>Node.js and NPM中的Node.js core Library is not

  • Node.js 中exports 和 module.exports 的区别

    Node.js 中exports 和 module.exports 的区别 require 用来加载代码,而 exports 和 module.exports 则用来导出代码.但很多新手可能会迷惑于 exports 和 module.exports 的区别,为了更好的理解 exports 和 module.exports 的关系,我们先来巩固下 js 的基础.示例: test.js var a = {name: 1}; var b = a; console.log(a); console.log

随机推荐