NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

浅析 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:185147
用户头像

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

关注

评论 1 条评论

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

跟着动画学Go数据结构之选择排序

宇宙之一粟

golang 数据结构 选择排序 12月日更

建木持续集成平台v2.1.0发布

Jianmu

DevOps CI/CD 开源社区

Kubernetes 集群无损升级实践

vivo互联网技术

容器 云原生 服务器集群 Kubernetes 集群

【1分钟调研赢好礼】HarmonyOS Connect 视频课堂用户反馈问卷

HarmonyOS开发者

HarmonyOS

龙智宣布与ConnectALL成为合作伙伴 进一步提升DevOps解决方案水平

龙智—DevSecOps解决方案

DevOps ConnectALL 价值流 价值流管理

Hive查询的18种方式

编程江湖

大数据 hive

你可能不信,52小时能做出7款超酷产品!

LigaAI

程序员 技术 技术人生 技术分享 hackathon

从 WAN 到 SD-WAN 边缘设备的网络架构

devpoint

TLS ssl SD-WAN 12月日更

【LeetCode】在 D 天内送达包裹的能力Java题解

Albert

算法 LeetCode 12月日更

Redisson:这么强大的实现分布式锁框架,你还没有?

华为云开发者联盟

redis 分布式 分布式锁 可重入锁 Redisson框架

Go语言逆向技术:常量字符串

华为云开发者联盟

字符串 go语言 字符 逆向技术 常量字符串

被灵魂问倒:这个BUG为什么没测出来?

华为云开发者联盟

测试 bug 文档 测试用例 测试工程师

COG云原生优化遥感影像,瓦片切分的最佳实践

华为云开发者联盟

云原生 遥感影像 瓦片切分 云上遥感影像文件 华为云地理遥感平台

对话龙智专家,共探DevSecOps实践难点

龙智—DevSecOps解决方案

DevOps DevSecOps

实用机器学习笔记十九:模型验证

打工人!

人工智能 机器学习 深度学习 学习笔记 12月日更

以 Vuex 为引,一窥状态管理全貌

杨成功

JavaScript Vue 大前端 vuex

netty系列之:netty对SOCKS协议的支持

程序那些事

Java Netty 程序那些事 SOCKS 12月日更

读《思辨与立场》-07思维的标准

wood

28天写作 批判性思维 思辨与立场

10个比较不错的 JavaScript 库

编程江湖

JavaScript 前端开发

即构科技 RTC 实践与深度解析 | 内容合集

ZEGO即构

音视频 RTC 内容合集 技术实践 技术专题合集

Prometheus Exporter (三十二)Varnish Exporter

耳东@Erdong

Prometheus 28天写作 exporter 12月日更 Varnish

Android 8.0 下载安装进入【安装未知应用】页面,两步简化一步

阿策小和尚

28天写作 Android 小菜鸟 12月日更

书单 | “实战派”系列,每一本都是学好用好一门技术的“航空母舰”

博文视点Broadview

GaussDB(DWS)中共享消息队列实现的三大功能

华为云开发者联盟

线程 数据同步 GaussDB(DWS) 共享消息队列 共享消息

前端开发之JS中filter()的使用

@零度

JavaScript 前端开发

【征集令】寻找2022年鸿蒙智联“出行新爆款产品”

HarmonyOS开发者

HarmonyOS

19《重学JAVA》--集合(一)

杨鹏Geek

Java25周年 28天写作 12月日更

容器技术正在颠覆传统,重构整个软件世界

巨子嘉

容器 云原生

Java开发Excel数据导入mysql的实用小技巧

@零度

Java MySQL

龙智第四次荣登“2021上海软件和信息技术服务业高成长百家”名单

龙智—DevSecOps解决方案

上海软件和信息技术服务业

比特币挖矿与源码解析

恒生LIGHT云社区

比特币 区块链 挖矿

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