【ArchSummit架构师峰会】探讨数据与人工智能相互驱动的关系>>> 了解详情
写点什么

浅析 Node 进程与线程

  • 2021-02-01
  • 本文字数:6116 字

    阅读完需:约 20 分钟

浅析 Node 进程与线程

进程与线程是操作系统中两个重要的角色,它们维系着不同程序的执行流程,通过系统内核的调度,完成多任务执行。今天我们从 Node.js(以下简称 Node)的角度来一起学习相关知识,通过本文读者将了解 Node 进程与线程的特点、代码层面的使用以及它们之间的通信。


概念


首先,我们还是回顾一下相关的定义:


进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。


线程是程序执行中一个单一的顺序控制流,它存在于进程之中,是比进程更小的能独立运行的基本单位。


早期在单核 CPU 的系统中,为了实现多任务的运行,引入了进程的概念,不同的程序运行在数据与指令相互隔离的进程中,通过时间片轮转调度执行,由于 CPU 时间片切换与执行很快,所以看上去像是在同一时间运行了多个程序。


由于进程切换时需要保存相关硬件现场、进程控制块等信息,所以系统开销较大。为了进一步提高系统吞吐率,在同一进程执行时更充分的利用 CPU 资源,引入了线程的概念。线程是操作系统调度执行的最小单位,它们依附于进程中,共享同一进程中的资源,基本不拥有或者只拥有少量系统资源,切换开销极小。


单线程?


我们常常听到有开发者说 “Node.js 是单线程的”,那么 Node 确实是只有一个线程在运行吗?

首先,执行以下 Node 代码(示例一):


// 示例一require('http').createServer((req, res) => {  res.writeHead(200);  res.end('Hello World');}).listen(8000);console.log('process id', process.pid);
复制代码


Node 内建模块 http 创建了一个监听 8000 端口的服务,并打印出该服务运行进程的 pid,控制台输出 pid 为 35919(可变),然后我们通过命令 top -pid 35919 查看进程的详细信息,如下所示:


PID    COMMAND      %CPU TIME     #TH  #WQ  #POR MEM    PURG CMPRS  PGRP  PPID  STATE    BOOSTS     %CPU_ME35919  node         0.0  00:00.09 7    0    35   8564K  0B   8548K  35919 35622 sleeping *0[1]      0.00000
复制代码


我们看到 #TH (threads 线程) 这一列显示此进程中包含 7 个线程,说明 Node 进程中并非只有一个线程。事实上一个 Node 进程通常包含:1 个 Javascript 执行主线程;1 个 watchdog 监控线程用于处理调试信息;1 个 v8 task scheduler 线程用于调度任务优先级,加速延迟敏感任务执行;4 个 v8 线程(可参考以下代码),主要用来执行代码调优与 GC 等后台任务;以及用于异步 I/O 的 libuv 线程池。


// v8 初始化线程const int thread_pool_size = 4; // 默认 4 个线程default_platform = v8::platform::CreateDefaultPlatform(thread_pool_size);V8::InitializePlatform(default_platform);V8::Initialize();
复制代码


其中异步 I/O 线程池,如果执行程序中不包含 I/O 操作如文件读写等,则默认线程池大小为 0,否则 Node 会初始化大小为 4 的异步 I/O 线程池,当然我们也可以通过 process.env.UV_THREADPOOL_SIZE 自己设定线程池大小。需要注意的是在 Node 中网络 I/O 并不占用线程池。


下图为 Node 的进程结构图:



为了验证上述分析,我们运行示例二的代码,加入文件 I/O 操作:


// 示例二require('fs').readFile('./test.log', err => {  if (err) {    console.log(err);    process.exit();  } else {    console.log(Date.now(), 'Read File I/O');  }});console.log(process.pid);
复制代码


然后得到如下结果:


PID    COMMAND      %CPU TIME     #TH  #WQ  #POR MEM    PURG CMPR PGRP  PPID  STATE    BOOSTS     %CPU_ME %CPU_OTHRS39443  node         0.0  00:00.10 11   0    39   8088K  0B   0B   39443 35622 sleeping *0[1]      0.00000 0.00000
复制代码


此时 #TH 一栏的线程数变成了 11,即大小为 4 的 I/O 线程池被创建。至此,我们针对段首的问题心里有了答案,Node 严格意义讲并非只有一个线程,通常说的 “Node 是单线程” 其实是指 JS 的执行主线程只有一个


事件循环


既然 JS 执行线程只有一个,那么 Node 为什么还能支持较高的并发?


从上文异步 I/O 我们也能获得一些思路,Node 进程中通过 libuv 实现了一个事件循环机制(uv_event_loop),当执行主线程发生阻塞事件,如 I/O 操作时,主线程会将耗时的操作放入事件队列中,然后继续执行后续程序。


uv_event_loop 尝试从 libuv 的线程池(uv_thread_pool)中取出一个空闲线程去执行队列中的操作,执行完毕获得结果后,通知主线程,主线程执行相关回调,并且将线程实例归还给线程池。通过此模式循环往复,来保证非阻塞 I/O,以及主线程的高效执行。


相关流程可参照下图:



子进程


通过事件循环机制,Node 实现了在 I/O 密集型(I/O-Sensitive)场景下的高并发,但是如果代码中遇到 CPU 密集场景(CPU-Sensitive)的场景,那么主线程将长时间阻塞,无法处理额外的请求。为了应对 CPU-Sensitive 场景,以及充分发挥 CPU 多核性能,Node 提供了 child_process 模块(官方文档,https://nodejs.org/api/child_process.html)进行进程的创建、通信、销毁等等。


创建


child_process 模块提供了 4 种异步创建 Node 进程的方法,具体可参考 child_process API,这里做一下简要介绍。


  • spawn 以主命令加参数数组的形式创建一个子进程,子进程以流的形式返回 data 和 error 信息。

  • exec 是对 spawn 的封装,可直接传入命令行执行,以 callback 形式返回 error stdout stderr 信息

  • execFile 类似于 exec 函数,但默认不会创建命令行环境,将直接以传入的文件创建新的进程,性能略微优于 exec

  • fork 是 spawn 的特殊场景,只能用于创建 node 程序的子进程,默认会建立父子进程的 IPC 信道来传递消息


通信


在 Linux 系统中,可以通过管道、消息队列、信号量、共享内存、Socket 等手段来实现进程通信。在 Node 中,父子进程可通过 IPC (Inter-Process Communication) 信道收发消息,IPC 由 libuv 通过管道 pipe 实现。一旦子进程被创建,并设置父子进程的通信方式为 IPC(参考 stdio 设置),父子进程即可双向通信。


进程之间通过 process.send 发送消息,通过监听 message 事件接收消息。当一个进程发送消息时,会先序列化为字符串,送入 IPC 信道的一端,另一个进程在另一端接收消息内容,并且反序列化,因此我们可以在进程之间传递对象。


示例


以下是 Node.js 创建进程和通信的一个基础示例,主进程创建一个子进程并将计算斐波那契数列的第 44 项这一 CPU 密集型的任务交给子进程,子进程执行完成后通过 IPC 信道将结果发送给主进程:

main_process.js


// 主进程const { fork } = require('child_process');const child = fork('./fib.js'); // 创建子进程child.send({ num: 44 }); // 将任务执行数据通过信道发送给子进程child.on('message', message => {  console.log('receive from child process, calculate result: ', message.data);  child.kill();});child.on('exit', () => {  console.log('child process exit');});setInterval(() => { // 主进程继续执行  console.log('continue excute javascript code', new Date().getSeconds());}, 1000);
复制代码

fib.js

// 子进程 fib.js// 接收主进程消息,计算斐波那契数列第 N 项,并发送结果给主进程// 计算斐波那契数列第 n 项function fib(num) {  if (num === 0) return 0;  if (num === 1) return 1;  return fib(num - 2) + fib(num - 1);}process.on('message', msg => { // 获取主进程传递的计算数据  console.log('child pid', process.pid);  const { num } = msg;  const data = fib(num);  process.send({ data }); // 将计算结果发送主进程});// 收到 kill 信息,进程退出process.on('SIGHUP', function() {  process.exit();});
复制代码


结果:


child pid 39974continue excute javascript code 41continue excute javascript code 42continue excute javascript code 43continue excute javascript code 44receive from child process, calculate result:  1134903170child process exit
复制代码

集群模式


为了更加方便的管理进程、负载均衡以及实现端口复用,Node 在 v0.6 之后引入了 cluster 模块(官方文档,https://nodejs.org/api/cluster.html),相对于子进程模块,cluster 实现了单 master 主控节点和多 worker 执行节点的通用集群模式。cluster master 节点可以创建销毁进程并与子进程通信,子进程之间不能直接通信;worker 节点则负责执行耗时的任务。


cluster 模块同时实现了负载均衡调度算法,在类 unix 系统中,cluster 使用轮转调度(round-robin),node 中维护一个可用 worker 节点的队列 free,和一个任务队列 handles。当一个新的任务到来时,节点队列队首节点出队,处理该任务,并返回确认处理标识,依次调度执行。而在 win 系统中,Node 通过 Shared Handle 来处理负载,通过将文件描述符、端口等信息传递给子进程,子进程通过信息创建相应的 SocketHandle / ServerHandle,然后进行相应的端口绑定和监听,处理请求。

cluster 大大的简化了多进程模型的使用,以下是使用示例:


// 计算斐波那契数列第 43 / 44 项const cluster = require('cluster');// 计算斐波那契数列第 n 项function fib(num) {  if (num === 0) return 0;  if (num === 1) return 1;  return fib(num - 2) + fib(num - 1);}if (cluster.isMaster) { // 主控节点逻辑  for (let i = 43; i < 45; i++) {    const worker = cluster.fork() // 启动子进程    // 发送任务数据给执行进程,并监听子进程回传的消息    worker.send({ num: i });    worker.on('message', message => {      console.log(`receive fib(${message.num}) calculate result ${message.data}`)      worker.kill();    });  }      // 监听子进程退出的消息,直到子进程全部退出  cluster.on('exit', worker => {    console.log('worker ' + worker.process.pid + ' killed!');    if (Object.keys(cluster.workers).length === 0) {      console.log('calculate main process end');    }  });} else {  // 子进程执行逻辑  process.on('message', message => { // 监听主进程发送的信息    const { num } = message;    console.log('child pid', process.pid, 'receive num', num);    const data = fib(num);    process.send({ data, num }); // 将计算结果发送给主进程  })}
复制代码

工作线程


在 Node v10 以后,为了减小 CPU 密集型任务计算的系统开销,引入了新的特性:工作线程 worker_threads(官方文档,https://nodejs.org/api/worker_threads.html)。通过 worker_threads 可以在进程内创建多个线程,主线程与 worker 线程使用 parentPort 通信,worker 线程之间可通过 MessageChannel 直接通信。


创建


通过 worker_threads 模块中的 Worker 类我们可以通过传入执行文件的路径创建线程。

const { Worker } = require('worker_threads');...const worker = new Worker(filepath);
复制代码

通信

使用 parentPort 进行父子线程通信


worker_threads 中使用了 MessagePort(继承于 EventEmitter,参考:https://developer.mozilla.org/en-US/docs/Web/API/MessagePort)来实现线程通信。worker 线程实例上有 parentPort 属性,是 MessagePort 类型的一个实例,子线程可利用 postMessage 通过 parentPort 向父线程传递数据,示例如下:


const { Worker, isMainThread, parentPort } = require('worker_threads');// 计算斐波那契数列第 n 项function fib(num) {  if (num === 0) return 0;  if (num === 1) return 1;  return fib(num - 2) + fib(num - 1);}if (isMainThread) { // 主线程执行函数  const worker = new Worker(__filename);  worker.once('message', (message) => {    const { num, result } = message;    console.log(`Fibonacci(${num}) is ${result}`);    process.exit();  });  worker.postMessage(43);  console.log('start calculate Fibonacci');  // 继续执行后续的计算程序  setInterval(() => {    console.log(`continue execute code ${new Date().getSeconds()}`);  }, 1000);} else { // 子线程执行函数  parentPort.once('message', (message) => {    const num = message;    const result = fib(num);    // 子线程执行完毕,发消息给父线程    parentPort.postMessage({      num,      result    });  });}
复制代码


结果:


start calculate Fibonaccicontinue execute code 8continue execute code 9continue execute code 10continue execute code 11Fibonacci(43) is 433494437
复制代码


使用 MessageChannel 实现线程间通信


worker_threads 还可以支持线程间的直接通信,通过两个连接在一起的 MessagePort 端口,worker_threads 实现了双向通信的 MessageChannel。线程间可通过 postMessage 相互通信,示例如下:


const {  isMainThread, parentPort, threadId, MessageChannel, Worker} = require('worker_threads'); if (isMainThread) {  const worker1 = new Worker(__filename);  const worker2 = new Worker(__filename);  // 创建通信信道,包含 port1 / port2 两个端口  const subChannel = new MessageChannel();  // 两个子线程绑定各自信道的通信入口  worker1.postMessage({ port: subChannel.port1 }, [ subChannel.port1 ]);  worker2.postMessage({ port: subChannel.port2 }, [ subChannel.port2 ]);} else {  parentPort.once('message', value => {    value.port.postMessage(`Hi, I am thread${threadId}`);    value.port.on('message', msg => {      console.log(`thread${threadId} receive: ${msg}`);    });  });}
复制代码


结果:


thread2 receive: Hi, I am thread1thread1 receive: Hi, I am thread2
复制代码

注意


worker_threads 只适用于进程内部 CPU 计算密集型的场景,而不适合于 I/O 密集场景,针对后者,官方建议使用进程的 event_loop 机制,将会更加高效可靠。


总结


Node.js 本身设计为单线程执行语言,通过 libuv 的线程池实现了高效的非阻塞异步 I/O,保证语言简单的特性,尽量减少编程复杂度。但是也带来了在多核应用以及 CPU 密集场景下的劣势,为了补齐这块短板,Node 可通过内建模块 child_process 创建额外的子进程来发挥多核的能力,以及在不阻塞主进程的前提下处理 CPU 密集任务。


为了简化开发者使用多进程模型以及端口复用,Node 又提供了 cluster 模块实现主-从节点模式的进程管理以及负载调度。由于进程创建、销毁、切换时系统开销较大,worker_threads 模块又随之推出,在保持轻量的前提下,可以利用更少的系统资源高效地处理 进程内 CPU 密集型任务,如数学计算、加解密,进一步提高进程的吞吐率。



头图:Unsplash

作者:BrunoLee

原文:https://mp.weixin.qq.com/s/P9k8SIIVrw6rV2Bvit_IMQ

原文:浅析 Node 进程与线程

来源:政采云前端团队 - 微信公众号 [ID:Zoo-Team]

转载:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

2021-02-01 00:185144
用户头像

发布了 78 篇内容, 共 28.7 次阅读, 收获喜欢 325 次。

关注

评论 1 条评论

发布
用户头像
线程是操作系统调度执行的最小单位 ???
2021-02-01 09:59
回复
没有更多了
发现更多内容

2020年5月云主机性能评测报告

博睿数据

云计算 服务器 公有云 机房 云主机

Java 序列化

CoderLi

Java 程序员 后端 序列化

下周要开始“卖桃者说”代班计划了

霍太稳@极客邦科技

日常

副业月赚 10 万的程序员是如何做销售的?

非著名程序员

程序员 独立开发者 程序人生 提升认知

架构师训练营作业(第二周)

王海

极客大学架构师训练营

游戏夜读 | 如何制作游戏?

game1night

Zookeeper Watcher 流程分析(结合源码)

CoderLi

Java zookeeper 源码分析 后端 Watcher

kubernetes简单入门(多图少字版)

绿星雪碧

Kubernetes 入门

Java | 原来 serialVersionUID 的用处在这里

YoungZY

Java

月薪 3W 的 Apple 微信编辑是这么发文章的 |如何发类似 Apple 微信公众号的文章效果

陈东泽 EuryChen

CSS 微信 大前端 apple 微信公众号

ZooKeeper 数据模型:节点的特性与应用

CoderLi

zookeeper 源码分析 数据模型 节点

华硕灵珑II笔记本电脑——自由work不设限

最新动态

中电标协提出并归口:《政务APP评价指标》团体标准开启制订工作

博睿数据

App 标准化 中电标协 政务信息化 博睿宏远

机器学习算法评估指标—2D语义分割

做技术BP的文案Gou

学习 2D 评估标准 语义分割

知乎Matisse图片库在Android10上拍照,预览问题

三爻

android

架构师训练营学习总结——框架设计【第二周】

王海

极客大学架构师训练营

JAVA 中的 CAS

犀利豆

Java 并发 CAS

情绪管理 - ABC理论

石云升

情绪控制 ABC理论 费斯汀格法则

2w字长文!手撸一套 Java 基础面试题

cxuan

Java 后端 Java25周年

小师妹学JavaIO之:MappedByteBuffer多大的文件我都装得下

程序那些事

Java io nio 小师妹 buffer

如何让程序员变得没朋友

四猿外

程序员 个人感悟 技术人生 经验分享

原创 | TDD工具集:JUnit、AssertJ和Mockito (二十一)编写测试-测试模板

编程道与术

Java 编程 TDD 单元测试 JUNI

自由是不是随心所欲?

Neco.W

个人成长 自由 控制

面向对象的三个基本特征(要素)

彭阿三

三要素 三个基本特征 封装、继承、多态

原创下载 | TDD工具集原创开源代码免费下载!

编程道与术

Java 开源 TDD 下载 代码

IP 基础知识全家桶,45 张图一套带走

小林coding

计算机网络 计算机基础 IP

重学 Java 设计模式:实战外观模式「基于SpringBoot开发门面模式中间件,统一控制接口白名单场景」

小傅哥

设计模式 小傅哥 重构 代码质量 代码坏味道

常用运筹学软件整理

张利东

iPad配置OpenVPN客户端

wong

ipad OpenVPN

钩陈/ 好中文作业:巴别塔

ZoomQuiet大妈

写作 大妈 是也乎 IMHO 蟒营®

Zookeeper-Access Control List(ACL)

CoderLi

Java zookeeper 源码分析 后端

浅析 Node 进程与线程_文化 & 方法_政采云前端团队_InfoQ精选文章