最近工作中又有可能需要写 Node.js 应用了,距离上次写 Node.js 应用也有好些年了,所以就开始重新熟悉下 Node.js 了。刚好最近又在学 Go,其最大的特点就是简单、轻量级的并发模型。非常容易用它编写一个能够充分利用硬件资源的高性能应用。于是不免想起以前学习 Node.js 时会遇到的问题:如何让 Node.js 充分利用多核 CPU 的资源。于是,让我发现了,Node.js 从 v10.5.0 开始引入 worker_threads 模块来解决该问题。并让我发现了这篇文章。

此文为译文,原文如下。

译自 Deep Dive into Worker Threads in Node.js


多年来,Node.js 一直都不是实现 CPU 密集型应用的最佳选择。其中最主要的原因就是 Node.js 仅仅是 Javascript 而 JavaScript 是单线程的。作为该问题一个解决方法,Node.js 从 v10.5.0 开始引入了实验性的 Worker Threads 概念,并将其体现在 worker_threads 模块,该模块从 Node.js v12 LTS 开始作为一个稳定功能模块提供出来。在这边文章中,我将会说明它们是如何工作的,怎样使用 worker threads 才能获取最好的性能。假如你对 Node.js worker threads 还不了解的话,我建议你查看它们的官方文档 。

注:文中引用的 Node.js 代码片段版本为 921493e 。

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

在 worker threads 出现前,就已经有很多种方案来完成基于 Node.js 的 CPU 密集型应用。常见的有如下几种:

  • 使用 child_process 模块,在子进程中运行耗费 CPU 的代码操作。
  • 使用 cluster 模块,在多个进程中运行耗费 CPU 资源的代码操作。
  • 使用第三方模块,如 Microsoft 的 Napa.js 。

但是,由于性能局限、额外的引入学习成本、接受度的不足、不稳定性以及文档缺失等原因,这其中没
一个方案是能被广泛接受的。

使用 worker threads 来执行 CPU 密集的代码操作

尽管 worker_threads 对于 JavaScript 的并发问题来说是一个优雅的解决方案,但是其实 JavaScript 本身并没有引进将多线程的语言特性。实际上,worker_threads 是通过允许应用可以运行多个独立 JavaScript workers,workers 和 其父 workers 可以通过 Node.js 来通信。听起来很困惑?

在 Node.js 中,每个 worker 有他自己的 V8 实例和事件循环机制(Event Loop)。但是,和子进程不同,workers 是可以共享内存的。

在此文的后面我会解释它们是如何能够拥有独立的 V8 实例和事件循环(Event Loop)的。不过我们先来看看我们能如何使用 worker threads。下面是一个基本用法的示例: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)
}

在上面的例子中,我们将一个数字传给另一个 worker 来计算其平方值。在完成计算后,该 child worker
会将其结果发送给 main worker。尽管这看起来很简单,但是如果你不了解 Node.js 的 worker threads
的话,还是会觉得有些困惑的。

worker threads 是如何工作的

JavaScript 并没有多线程的特性,所以 Node.js 的 Worker Threads 和其他支持多线程的高级语言在处理上是不一样的。

在 Node.js 中,一个 worker 的职责就是执行 parent worker 提供给他的代码片段(worker script)。这个 worker script 可以是一个单独的文件,也可以是一个能够被 eval 代码文本;它将被放在别的 worker 中独立运行,且 该 worker 和其 parent worker 之间是可以传递消息的。在我们的例子中,我们提供了 __filename 作为这个代码片段,那是因为 worker 和 parent worker 的执行代码是一样的,只是在其中通过 isMainThread 来区分了。

每一个 worker 都通过一个 message channel 来和其 parent worker 通信。child worker 可以通过 parentPort.postMessage 将信息写入信道,而 parent worker 需要通过 worker.postMessage() 来将消息写入信道。看看下图(图一)他们是如何工作的

一个信道就是一个简单的通信渠道。它有两个端口,被叫做 ‘ports’。在 JavaScript / NodeJS 中,信道的两端就只是被简单的叫做‘port1’ 和 ‘port2’……

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

现在,最大的问题就是,,既然 JavaScript 本身并不支持并行,那么两个 Node.js workers 是如何并行执行的呢?

答案就是 V8 Isolates 。

V8 Isolates 是一个独立的 chrome V8 运行实例,其有独立的 JS 堆和微任务队列。这就为每一个Node.js worker 独立运行提供了保障。其缺陷就是,workers 之间没法直接访问对方的堆。由于这个原因,每个 worker 都有其自己的 libuv event loop。

跨越 JS/C++ 的界限

Worker 的实例化和通信是通过 C++ 的 worker 来实现的。该模块的实现在 worker.cc 。

Worker 的实现通过 worker_threads 模块暴露在了 JavaScript 的用户空间。这个 JS 的实现被分成了两个 scripts :

  • [Worker 初始化脚本] - 负责初始化 worker 实例并设置 parent-child worker 之间的通信使得 parent worker 可以将 metadata 传递给 child worker。
  • [Worker 执行脚本] - 负责使用用户提供的 workerData 和 parent worker 提供的 metadata 来执行用户的 JS 脚本。

下图(图二)提供了一个更清晰的说明。看看该图描述了些什么

按照上面说的,我们可以把 worker 的设置进程分成两步。

  • 初始化 worker
  • 运行 worker

让我们看卡这两步都干了啥。

初始化阶段

  1. 用户空间的脚本通过 worker_threads 模块创建一个 worker 实例
  2. Node.js 的 parent worker 初始化脚本调用 C++ 模块,创建一个空的 C++ worker 对象。
  3. 当 C++ worker 对象被创建后,它生成一个线程 ID 并分配给自己
  4. 当 worker 对象被创建的时候,parent worker 会初创建一个空的始化信道(让我们称它为 IMC)。就是上面图二的 “Initialisation Message Channel”
  5. Node.js 的 parent worker 初始化脚本创建一个公共的 JS 信道(让我们称它为 PMC)。该信道是给用户空间的 JS 使用的,便于他们在 parent worker 和 child worker 之间通过 *.postMessage() 方法来传递消息。就是上面图一和图二的红色部分
  6. Node.js 的 parent worker 初始化脚本调用 C++ 模块,将初始化的 metadata 写入 IMC 来传递给 worker 的执行脚本。

初始化 metadata 是什么? worker 执行脚本启动时需要知道的数据,eg. 脚本名称、worker 数据、 PMC 的 port2 以及一些其他信息。
根据我们的例子来说,初始化 metadata 就是类似如下的一条信息:
嗨,Worker 执行脚本,使用 worker data {num: 5} 来运行 worker-simple.js。并将 PMC 的 port2 传递给它,以便它能从 PMC 读取或写入消息。

下面这个代码片段展示了初始化 metadata 是如何被写入到 IMC 的。

const kPublicPort = Symbol('kPublicPort');
// ...redacted...const { port1, port2 } = new MessageChannel();
this[kPublicPort] = port1;
this[kPublicPort].on('message', (message) => this.emit('message', message));
// ...redacted...this[kPort].postMessage({type: 'loadScript',filename,doEval: !!options.eval,cwdCounter: cwdCounter || workerIo.sharedCwdCounter,workerData: options.workerData,publicPort: port2,// ...redacted...hasStdin: !!options.stdin
}, [port2]);

在上面的片段中,this[kPort] 就是 IMC 的 end port。尽管此时已经向 IMC 写数据了,但是 worker 执行脚本还是无法获取这些数据,因为它还没有被启动呢。

运行阶段

此时,初始化阶段完成。然后,就会调用 C++ 来启动这个 worker 线程。

  1. 一个新的 v8 isolate 被创建并分配给这个 worker。这使得该 worker 可以拥有自己的运行时环境
  2. libuv 被初始化。这使得该 worker 可以拥有自己的 event loop
  3. Worker 初始化脚本被执行,启动 worker 的 event loop
  4. Worker 初始化脚本 调用 C++ 模块从 IMC 读取初始化 metadata
  5. Worker 执行脚本在 worker 中运行代码文件或代码片段。在我们的例子中就是 worker-simple.js

下面这个代码片段展示了 worker 是如何执行脚本的。

const publicWorker = require('worker_threads');// ...redacted...port.on('message', (message) => {if (message.type === 'loadScript') {const {cwdCounter,filename,doEval,workerData,publicPort,manifestSrc,manifestURL,hasStdin} = message;// ...redacted...initializeCJSLoader();initializeESMLoader();publicWorker.parentPort = publicPort;publicWorker.workerData = workerData;// ...redacted...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 filenamerequire('module').runMain();}}// ...redacted...

发现了什么吗?

在上面的代码片段中,你是否有发现 workerDataparentPort 属性是在 require('worker_threads') 中被 worker 执行脚本设置的?

这就是为什么 workerDataparentPort 属性只能在 child worker thread 的代码而不是 parent worker thread 的代码中被访问了。假如你尝试在 parent worker 的代码中获取这些属性,你只能得到 null

更好的使用 worker threads

现在我们了解了 Node.js Worker Threads 是怎么工作的了。了解他们的原来能让我们更好的使用 worker threads 来获取最佳的性能。当编写复杂的应用的时候(比 worker-simple.js 复杂的多),我们需要牢记下面两个点

  • 尽管 worker threads 比实际的进程更加轻量级,但是大量频繁的创建使用它也是很昂贵的
  • 使用 worker threads 来并行的执行 I/O 操作时不划算的,请直接使用 Node.js 自身的 I/O 操作,那会更高效

为了克服上述的第一个问题吗,我们需要实现 “Worker 线程池”。

Worker 线程池

通过使用线程池技术,使得当新任务来临时,我们可以通过 parent-child 信道将 其传递给一个可用的 worker 来执行它,一旦这个 worker 完成了该 task,其可以用过同样的信道来将其执行结果传递过来。一旦正确的实现线程池,那么其将显著的提升应用性能。因为它能够减少额外的创建 threads 消耗。同样值得一提的是,创建大量线程并不一定就是特别高效了,因为其还受制于硬件的资源。

下图是三种情况的一个性能比较,其实现了相同的功能:接受一个 string 然后给他加密并返回。三种不同的服务如下:

  • 没使用多线程的服务(no multi-threading)
  • 使用了多线程但没有使用线程池的服务(multi-threading(no thread pool))
  • 使用了多线程和线程池的服务(multi-threading(with thread pool))

然而,Node.js 官方目前并没有提供线程池的使用。所以你需要依赖第三方的实现或者自己实现一个。这是我自己实现的一个 worker pool 。它还不能被用于生产环境

[译]深入理解 Node.js Worker Threads相关推荐

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

    多年以来,Node.js 都不是实现高 CPU 密集型应用的最佳选择,这主要就是因为 JavaScript 的单线程.作为对此问题的解决方案,Node.js v10.5.0 通过 worker_thr ...

  2. 理解node.js(Understanding node.js)

    因为最近自己在学习node.js,刚开始学.看到这篇文章挺有意思,介绍了一下node.js有助于理解基于事件驱动的回调,就翻译了一下. 英文原文: Understanding node.js 理解no ...

  3. [译]理解Node.js事件驱动机制

    学习 Node.js 一定要理解的内容之一,文中主要涉及到了 EventEmitter 的使用和一些异步情况的处理,比较偏基础,值得一读. 大多数 Node.js 对象都依赖了 EventEmitte ...

  4. [译]理解 Node.js 事件驱动架构

    原文地址:Understanding Node.js Event-Driven Architecture 大部分 Node 模块,例如 http 和 stream,都是基于EventEmitter模块 ...

  5. linux进程退出所有tcp数据才发送,深入理解Node.js 进程与线程(8000长文彻底搞懂)...

    前言 进程与线程是一个程序员的必知概念,面试经常被问及,但是一些文章内容只是讲讲理论知识,可能一些小伙伴并没有真的理解,在实际开发中应用也比较少.本篇文章除了介绍概念,通过Node.js 的角度讲解进 ...

  6. 理解Node.js(译文)

    前言 总括 :这篇文章十分生动形象的的介绍了Node,满足了读者想去了解Node的需求.作者是Node的第一批贡献者之一,德国前端大神.译者觉得作者的比喻很适合初学者理解Node,特此翻译. 译者 : ...

  7. 【译】使用Node.js创建命令行脚本工具

    通过本文将一步步带领你利用Node.js来创建命令行脚本工具.在我的职业生涯中已经写过了上百个 `Bash` 脚本,但我的 `Bash` 依然写得很糟糕,每一次我都不得不去查一些简单逻辑结构的语法.如 ...

  8. 理解Node.js的异步非阻塞I/O模型

    对后台服务器编程不清楚,通过在网上查资料也就大概有写了解. Apache对并发请求的处理方式是,对每个请求就创建一个线程处理,这个线程是堵塞的.因为线程的是占用内存的,所以一台服务器能支持的并发线程量 ...

  9. ajax 高并发请求,理解node.js处理高并发请求原理

    很少分享技术文章,写的不好的地方请大家多多指教,本文是自己对于node.js的一些见解,如有纰漏请在评论区交流. 高并发策略 通常高并发的解决方案就是提供多线程模型,服务器为每个客户端请求分配一个线程 ...

最新文章

  1. sql将一列拆分为多列_【Excel实用技巧】把一列数据拆分为多列的三个菜鸟招数,你还有更菜的方法吗?...
  2. STM32F103 CAN中断发送功能的再次讨论
  3. Istio 在阿里云容器服务的部署及流量治理实践
  4. pr下雪下雨_图像增强:下雨,下雪。 如何修改照片以训练自动驾驶汽车
  5. ubuntu安装sublime3并配置python3环境
  6. 发现读纸质媒介比电子媒介的乐趣大多了
  7. TAB三巨头虚拟币的运用
  8. 为什么需要分布式配置中心
  9. ionic4 组件的使用(一)
  10. x等于5y等于8c语言表达式,《C语言程序设计》复习参考题.doc
  11. 滚动吸顶效果--四种方式实现
  12. 微服务架构实践之邮件通知系统改造
  13. 通过有限差分和matlab矩阵运算直接求解一维薛定谔方程,通过有限差分和MATLAB矩阵运算直接求解一维薛定谔方程...
  14. 跟小博老师一起学习MyBatis ——MyBatis搭建运行环境
  15. HTTP 最强资料大全
  16. 山东如意路嘉纳高级定制西装品牌惊艳亮相intertextile面料展 - 服装资讯中心 - 华衣网...
  17. 机房收费系统之软件需求说明书
  18. 笔试代码题--搜狗--汪仔做对的题数范围
  19. Go软件安装-已成功测试-20210413
  20. 矩阵运算——矩阵乘除法python

热门文章

  1. Scala - 睡眠排序应用与分析
  2. 利用pyecharts实现公交地铁站点地理信息地图可视化
  3. listbox java_如何将所选项从一个listBox添加到另一个listBox
  4. yolov7训练BDD100k自动驾驶环境感知2D框检测模型
  5. ESXi服务器勒索补丁升级方法
  6. Linux sed 命令简要
  7. 游戏服务器 c语言,C++游戏服务器编程从入门到掌握视频教程(全)
  8. 【论文阅读】Cross-X Learning for Fine-Grained Visual Categorization
  9. 微信小程序-画板工具实现
  10. 浊度传感器的使用(STM32实现)