深入理解 Node.js 中的 Worker 线程使用帮助_线程教程视频

深入理解 Node.js 中的 Worker 线程使用帮助

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

深入理解 Node.js 中的 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:

深入理解 Node.js 中的 Worker 线程使用帮助_线程教程视频

一个 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 以更清晰的方式解释了这个过程:

深入理解 Node.js 中的 Worker 线程使用帮助_线程教程视频

基于上述,我们可以将 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 更复杂的应用时,需要记住以下两个主要的关注点:

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

  2. 使用 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 线程使用帮助_线程教程视频

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

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

海计划公众号
(0)
上一篇 2020/03/19 07:13
下一篇 2020/03/19 07:13

您可能感兴趣的内容

  • React中常被问到的面试题教程视频_面试入门教程

    什么是 JSX要了解 JSX,首先先了解什么三个主要问题,什么事 VDOM,差异更新和 JSX 建模:VDOM,也叫虚拟 DOM,它是仅存于内存中的 DOM,因为还未展示到页面中,所以称为 VDOMvar vdom = document.createElement(“div”);上面这一句就是最简单的虚拟 DOMvar vdom = document.cre

    2020/03/24
  • 一位高级软件工程师的自述:这个职位到底要做什么工作?使用说明_工程师菜鸟知识

    本文作者 Joy Ebertz 接触过的几乎每一家软件公司都设有技术晋升与管理晋升两条职业道路,这意味着如果只走技术方向,技术人员也完全可以在不出任管理职务的前提下达到相同的高阶职级。但与此同时,Joy Ebertz 所参加的几乎所有职业演讲或讨论小组都充斥着管理人员。现在,Joy Ebertz 终于明白从宏观层面来看,管理者到底需要做什么、管理的晋升通道又

    2020/03/24
  • 探索小程序实现菜鸟教程下载_小程序教程视频

    随着小程序的发展与功能的逐步完善,越来越多的产品需要小程序与 APP 的功能能有一些共性,社区跨平台的解决方案越来越多,比如 taro 等为代表的把一套代码编译成多端运行的机制,本文会使用 Swift 作为原生语言,在 iOS 应用上运行一个小程序 Demo, 使用 Android && React Native 也可以采用同样的思路实现。相关代码仓库: h

    2020/03/20
  • TypeScript基础零基础入门_TypeScript菜鸟教程下载

    类型注意事项数组类型有两种类型注解方式,特别注意第二种使用 TS 内置的 Array 泛型接口。let arr1: number[] = [1,2,3]
    // 下面就是使用 TS 内置的 Array 泛型接口来实现的
    let arr2: Array = [1,2,3,”abc”]元组类型元组是一种特殊的数组,限定了数组元素的

    2020/03/24
  • 可用的CSS文字两端对齐小白入门_对齐基础知识教程

    最近在工作项目中接触到Web界面设计的问题,要实现文字两端对齐的效果。在网上搜索了一下,差不多都是互相转帖,用的都是类似的技巧:text-align:justify;
    text-justify:inter-ideograph;但问题是,我怎么就看不到效果呢?无论是英文还是中文,在IE和chrome下都不起作用。后来,终于在StackOverflow上找到解决

    2020/03/22
  • Redis面试常问的知识点总汇基础入门_Redis入门基础知识

    1.什么是redis?Redis 是一个基于内存的高性能key-value数据库。2.Reids的特点Redis本质上是一个Key-Value类型的内存数据库,很像memcached,整个数据库统统加载在内存当中进行操作,定期通过异步操作把数据库数据flush到硬盘上进行保存。因为是纯内存操作,Redis的性能非常出色,每秒可以处理超过 10万次读写操作,是

    2020/03/26
  • 打一个通用UMD包入门攻略_umd入门基础

    有这样一个场景,客户端运行很久,但是法务部和数据部需要收集用户的一些信息,这些信息收集好之后需要进行相应的数据处理,之后上报到服务端。客户端提供一个纯粹的 JS 执行引擎,不需要 WebView 容器。iOS 端有成熟的 JavaScriptCore、Android 可以使用 V8 引擎。这样一个引擎配套有一个 SDK,访问 Native 的基础能力和数据运

    2020/03/20
  • 你需要知道的CSS 属性选择器!小白基础_选择器菜鸟教程

    属性选择器非常神奇。它们可以使你摆脱棘手的问题,帮助你避免添加类,并指出代码中的一些问题。但是不要担心,虽然属性选择器非常复杂和强大,但是它们很容易学习和使用。在本文中,我们将讨论它们是如何运行的,并给出一些如何使用它们的想法。通常将 HTML 属性放在方括号中,称为属性选择器,如下:[href] {color: red;
    }
    这样任何具有href属性的且没

    2020/03/20
  • axios取消某个发送的http请求和响应基础入门_请求菜鸟指南

    需求场景用户在点击购买或者其他操作的时候,http响应比较慢在没有收到反馈前,用户点击返回或者跳转到其他页面时,中断当前页面的请求和响应实例化CancelToken首页需要实例化一个CancelTokenimport axios from “axios”;
    const CancelToken = axios.CancelToken;发起请求然后在发起某个请求

    2020/03/24
  • css常用代码基础知识教程_css小白基础

    1.禁止div点击//css属性:pointer-events: none; //或者定义属性,在js中添加:$(“.原类名”).addClass(“新类名”);
    //js://禁用$.fn.disable = function () {$(this).addClass(“disable”);};//启用$.fn.enable = function

    2020/03/29
  • lowdb新手入门_小型本地静态JSON文件的数据库

    lowdb新手入门 GitHub:https://github.com/typicode/lowdb 简介描述:小型本地静态JSON文件的数据库 lowdb 是一个基于 Lodas…

    2020/03/06
  • anime.js基础入门教程_强大的动画库插件,可以和CSS3,SVG,DOM和JS一起工作,制作出各种高性能,平滑过渡的动画效果

    anime.js基础入门 官方网址:http://animejs.com/ GitHub:https://github.com/juliangarnier/anime 简介描述:强…

    2020/03/05
  • CSS中zoom和scale的差异基础入门_区别使用说明

    先说原理zoom和scale这两个东西都是用于对元素的缩放,但两者除了兼容性之外还有一些不同的地方。zoom缩放会将元素保持在左上角,而scale默认是中间位置,可以通过transform-origin来设置。另外他们执行的渲染顺序也不同zoom可能影响到盒子的计算。例子
    div {width:300px;height:100px;border

    2020/03/22
  • topcoat零基础入门_一款为简洁高速Web应用提供CSS开发的工具

    topcoat小白教程 官方网址:http://topcoat.io GitHub:https://github.com/topcoat/topcoat 简介描述:一款为简洁高速W…

    2020/03/06
  • 20年程序员分享编程经验新手入门_经验攻略教程

    从11岁时,我就一直在编程,并且一直都很喜欢技术和编程。这些年来,我积累了一些艰难又容易的经验。作为一名程序员,你或许还没这些经验,但我会把它们献给那些想从中学到更多的朋友。我会持续更新这些经验,我可能还会有更多的感想,但就我这20年来看,我想下面这个列表中基本不需要增添额外的东西了。下面就是我至今最难忘的经验。1. 估算解决问题所需要的时间。不要怕,承认吧

    2020/03/29
  • Codekit教程视频为Web前端打造的全能型神器

    Codekit基础入门 官方网址:https://codekitapp.com/ 简介描述:为Web前端打造的全能型神器 CodeKit简介 这是一款Mac平台集代码编辑与文件压缩…

    2020/03/05