速来报名!AICon北京站鸿蒙专场~ 了解详情
写点什么

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

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

关注

评论 1 条评论

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

淘宝订单信息获取接口,淘宝订单信息获取API

tbapi

淘宝API接口 淘宝店铺订单接口 天猫店铺订单接口 淘宝店铺订单API

覆盖30个省市自治区万余家医疗机构,原生鸿蒙助力打造智慧医疗便利体验

最新动态

海外网络加速的技术手段有哪些?

Ogcloud

网络加速 企业组网 海外网络加速 企业网络加速 CDN网络加速

极狐GitLab如何禁止从 UI 上下载代码?

极狐GitLab

gitlab

云速搭助力用友 BIP 平台快速接入阿里云产品

阿里巴巴云原生

阿里云 云原生 BIP

MES系统助力中小企业数字化转型

万界星空科技

数字化转型 生产管理系统 mes 万界星空科技 中小企业数字化转型

怎么在线画用户旅程图?分享10个用户旅程图模板!

职场工具箱

职场 产品经理 在线白板 绘图工具 用户旅程图

更经济实惠的SD-WAN组网

Ogcloud

SD-WAN 企业组网 SD-WAN组网 SD-WAN服务商 SDWAN

离散元仿真技术与AI融合,助力广泛行业实现创新突破

Altair RapidMiner

AI 仿真 DEM altair 离散元

项目管理十大知识领域详解:从理论到实践

爱吃小舅的鱼

项目管理 项目成本管理 项目整合

比 Copilot 快两倍以上!在我的开源项目 AI Godot 桌宠中用通义灵码解决问题

阿里云云效

阿里云 云原生

瞧瞧别人的Controller,那叫一个优雅!

快乐非自愿限量之名

Java 前端

百度发布 AI 眼镜:全球首搭中文大模型,支持边走边问;OpenAI 联合创始人宣布回归,主抓重大技术创新丨 RTE 开发者日报

声网

【JIT/极态云】技术文档--审批事件

武汉万云网络科技有限公司

Python 如何根据给定模型计算权值

不在线第一只蜗牛

Python 深度学习

权限系统:6个权限概念模型设计

不在线第一只蜗牛

数据库 大数据 运维

七招提升工作效率

俞凡

生产力

预算有限但想独立定制体育直播平台?3种开发方式揭秘

软件开发-梦幻运营部

2024-11-13:求出所有子序列的能量和。用go语言,给定一个整数数组nums和一个正整数k, 定义一个子序列的能量为子序列中任意两个元素之间的差值绝对值的最小值。 找出nums中长度为k的所有子

福大大架构师每日一题

福大大架构师每日一题

GitLab 出现 500错误怎么解决?

极狐GitLab

gitlab

比 Copilot 快两倍以上!在我的开源项目 AI Godot 桌宠中用通义灵码解决问题

阿里巴巴云原生

阿里云 云原生

语音 AI 革命:未来,消费者更可能倾向于与 AI 沟通,而非人工客服

声网

业务、技术、管理,谁才是指标平台的用户?

Aloudata

数据仓库 数据分析 指标管理 指标平台 指标开发

Spring AI 再更新:如何借助全局参数实现智能数据库操作与个性化待办管理

EquatorCoco

人工智能 数据库 spring

快递鸟物流跟踪API代码参数接入流程

快递鸟

快递物流

八招解决 Golang 性能问题

俞凡

golang

澳鹏白皮书:2024年AI全景报告

澳鹏Appen

人工智能 行业报告

优化数据管理,提升监测效率:TDengine与新疆地环院达成合作

TDengine

tdengine 时序数据库 数据库·

一图看懂云消息队列 RabbitMQ 版对比开源优势

阿里巴巴云原生

阿里云 云原生

用 Zap 轻松搞定 Go 语言中的结构化日志

左诗右码

应用网关的演进历程和分类

阿里巴巴云原生

阿里云 云原生 网关

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