QCon北京「鸿蒙专场」火热来袭!即刻报名,与创新同行~ 了解详情
写点什么

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

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

关注

评论 1 条评论

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

NineData和Klustron完成产品兼容互认证

NineData

数据库 数据管理 NineData Klustron 泽拓昆仑

WorkPlus构建便捷高效的企业移动门户平台

BeeWorks

跨境电商如何利用item_get-根据ID取商品详情(shopee.item_get)提升用户体验?

技术冰糖葫芦

API 编排

4个知名企业Offer拿到手软,他是怎么做到的?附面试真题

测试人

软件测试

合合信息启信数据发布园区金融解决方案,助力银行精准服务“十四五”特色产业

合合技术团队

大数据 金融 合合信息 启信慧眼

火山引擎VeDI:新增微信小程序广告A/B实验功能,助力企业降低获客成本

字节跳动数据平台

数据库 大数据 ab测试 企业号 1 月 PK 榜 对比实验

测试管理 | 入班第二个月后拿到4个知名企业Offer,他是怎么做到的?

测吧(北京)科技有限公司

测试

AI大模型在电商商家端自定义报表分析中的应用与实践

百度开发者中心

人工智能 电商 大模型

大规模集群下,如何快速实现无死角网络连通性的主动巡检

ii2day

云原生 压力测试 Cloud Native kubernetes 运维 自动巡检

QSpace Pro 一款简洁高效的多窗格文件管理器,灵活且实用!

Rose

Mac软件 QSpace 多窗格文件管理器

用游戏盾会掉线吗,游戏出现掉线或者卡顿的可能有哪些原因

德迅云安全杨德俊

软件测试/测试开发|学习两个个月后拿到4个知名企业Offer,他是怎么做到的?

霍格沃兹测试开发学社

荣耀开发者大会 2023 · 一张图读懂极致体验分论坛

荣耀开发者服务平台

阿里云 Flink 原理分析与应用:深入探索 MongoDB Schema Inference

Apache Flink

foobar2000 for mac多功能音频播放器 v2.6.1免激活版

Rose

mac音乐播放器 foobar2000中文版 foobar2000破解版

【技术探讨】如何选择一款距离远的无线通信模块?

Geek_ab1536

热更新适配ibatis原理浅析

京东科技开发者

想在 Mac 里装 Windows ?试试 Parallels Desktop虚拟机!

Rose

Windows系统 Mac双系统安装 Parallels Desktop

《Hive编程指南》读书笔记

京东科技开发者

得物从零构建亿级消息推送系统的送达稳定性监控体系技术实践

JackJiang

网络编程 即时通讯 IM

人工智能大模型多场景应用原理解析

百度开发者中心

人工智能 图像识别 大模型

CES 2024的亮点仅仅聚焦AI深度赋能和产业创新吗?| DALL-E 3、Stable Diffusion等20+ 图像生成模型综述

GPU算力

AI大模型低成本快速定制秘诀:RAG和向量数据库

百度开发者中心

人工智能 数据库 大模型

万字图解|深入揭秘 (数据链路层、物理层) 工作原理

云舒编程

IP 物理层 路由 图解网络 数据链路层

定制+轻量级低代码:满足客户个性需求的最佳实践

天津汇柏科技有限公司

低代码 软件定制开发 软件开发定制

苹果macos效率神器alfred5新功能介绍 及alfred 5汉化包下载

Rose

mac软件下载 Alfred 5破解版 Alfred 中文 Mac效率办公软件

租赁舞台LED屏的注意事项及问题排除

Dylan

活动 LED显示屏 led显示屏厂家 效果广告

WorkPlus移动应用管理平台,助力企业实现高效移动办公

BeeWorks

万字图解 | 深入揭秘IP层工作原理

云舒编程

IP MTU 路由表 子网划分 图解网络

三个方面浅析数据对大语言模型的影响

华为云开发者联盟

人工智能 华为云 华为云开发者联盟 大语言模型

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