Node.js 进程/线程管理

语言: CN / TW / HK

背景

今天在看Node.js hmr相关的资料时,看到nodemon重启服务的过程有点疑惑,流程是这样的: 1 通过pstree插件获取所有子进程,并关闭所有子进程; 2 关闭主进程; 3 启动服务(优先通过child_process.fork启动,默认child_process.spawn); 非常疑惑,难道关闭主进程之后,所有子进程仍会存在吗?带着这个疑惑,深度整理下进程/子进程/线程(本文章节较多,断断续续记录了很多内容,又花了一天时间整理,建议可以跟着例子做一遍),及应用场景;

注:本人使用macOS,主要是介绍进程在Node.js中的应用

before start

开始之前先说说下涉及到的Node.js相关的接口,另本文进行了大量测试,有一些常用的linux命令需要了解下,方便调试 1 查看端口占用的进程信息; lsof -i:port 2 查看tcp端口占用情况; netstat -anvp tcp 3 查看进程状态; top -pid pid 4 查看子进程; pstree -p pid 5 查看线程; ps -M pid 6 杀死进程; kill -9 pid(通过pid杀死进程)/pkill command(通过进程名称杀死进程, e.g. pkill node,杀死所有node应用) 本文主要是使用Node.js提供的四个api, process, child_process, cluster, worker_threads.

process

process主要提供了以下功能:
1、EventEmitter的实例,可以监听/emit进程的各个阶段事件(beforeExit, exit, onece, warnning, rejectionHandled等); process.on('exit', (code) => { console.log(code) }) // 使用 process.exit 事件杀死进程 或 只提交emit触发监听事件(在处理一些异常的时候,可以通过emit进行触发) process.exit(1) // 1 process.emit('exit', 'just emit, not exit') // just emit, not exit 2、获取启动参数;e.g. // 启动 node index.js -x 3 -y 4 // 打印参数,还可以通过一些工具来序列化参数,更加方便使用,e.g. argvs console.log(argv, argv0) // ['node', 'index.js', '-x', '3', '-y', '4'] 'node' 3、提供进程信息(pid,ppid,platform,etc.);

child_process

1、shell语句/文件执行api, child_process.execFile()/child_process.exec();
2、fork新的子进程, child_process.fork();
3、spawn语句,用新的进程执行shell语句;
4、eventEmitter实例,提供一些进程管理api和进程信息api(subprocess.kill(), subprocess.exitCode(), subprocess.pid, etc.)
这里主要是需要区分,exec(execFile), fork, spawn这三个api的区别,
相同点:
1、三个api都是用来创建新的子进程的; 2、exec(execFile),fork都是基于spawn进行拓展的; 不同点:
// 应用场景不同 1 exec(execFile)是执行一些shell命令或shell脚本文件(execFile是执行shell命令,exec是执行shell脚本文件,这点容易混淆),且不需要和父进程通信; 2 fork()是复制并创建一个新的子进程,通常是在已有进程上进行fork(); 3 以上场景不合适或者不能实现需求的话就用spawn; // 性能/便捷性 1、对于执行shell(性能顺序由高到底),execFile->exec->spawn;对于复制新的子进程, fork()->spawn(); 2、spawn是最基本的api,但相对的在具体的场景上,性能/便捷性却是最低的(这个是相对的,如果你的实现可以比node性能更好,请去提pr); // 传参/通信/回调不同 1、exec(execFile)支持回调函数,并且会将(err, stdout, stderr)传入回调函数; 2、fork(),复制新建子进程,并已构建IPC通信(关于通信在另一篇文章细说); // 什么时候结束 1 exec(execFile)执行完shell语句/脚本后就exit; 总结,其实就像数组的方法有很多,最基本是是for循环,但我们在具体的场景上应该用性能更高,且性能更高,更语义化的api。

cluster

node提供的集群管理接口,基于eventEmitter,提供了fork, isPrimary, isWorker, workers等方法;官网例子如下:
``` import cluster from 'cluster'; import http from 'http'; import { cpus } from 'os'; import process from 'process';

const numCPUs = cpus().length;

// 兼容cluster.isMaster if (cluster.isPrimary || cluster.isMaster) { console.log(Primary ${process.pid} is running);

// Fork workers. for (let i = 0; i < numCPUs; i++) { cluster.fork(); }

cluster.on('exit', (worker, code, signal) => { console.log(worker ${worker.process.pid} died); }); } else { // Workers can share any TCP connection // In this case it is an HTTP server http.createServer((req, res) => { res.writeHead(200); res.end('hello world\n'); }).listen(8000);

console.log(Worker ${process.pid} started); } ``` 看到这里其实一头雾,这不是创建n个进程监听一个端口吗?
原以为操作是在cluster.fork方法中,但找了很久没发现有特殊的地方,后来看了下http.createServer方法,里面区分了isPrimary和isworker的创建方式,具体可以参考从源码分析Node的Cluster模块, 简单来说就是主进程监听了端口,主进程通过ipc通信方式将服务分配到子进程处理新的连接和数据;

worker_threads

worker_threads允许js创建新的线程并行执行任务,主要提供了:
1、获取线程信息的api(isMainThread, parentPort, threadId等);
2、通信类(MessageChannel, MessagePort),提供了线程和进程之间通信的方法(后面通信篇详细说明);
3、Worker类,基于eventEmitter,提供了线程管理的一些方法(线程开启,关闭);e.g. // 开启新的进程 new Worker(file)

进程

概念: 进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
概念很抽象,我认为理解为一个占用一些资源的正在执行的程序即可。在Node.js中,就是通过node执行我们代码的程序,e.g. ``` import Koa from 'koa'

const app = new Koa()

app.use((ctx, next) => { ctx.body = 'hello world' }) app.listen(3002) 我们可以通过端口可以查询到进程的pid,通过pid可以查询到进程运行状态;e.g. lsof -i:3002 //COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME //node 82410 vb 23u IPv6 * 0t0 TCP :exlm-agent (LISTEN) top -pid 82410 // PID COMMAND %CPU TIME #TH #WQ #PORTS MEM PURG CMPRS PGRP PPID STATE BOOSTS %CPU_ME %CPU_OTHRS UID FAULTS COW MSGSENT MSGRECV SYSBSD SYSMACH CSW PAGEINS // 15715 node 0.0 00:02.36 8 0 30 106M 0B 102M 4084 1 sleeping 0[1] 0.00000 0.00000 502 64987 629 106 47 12045 351 2730 0
``` 通过top命令我们可以查看到进程的资源占用情况,这里主要的指标项是内存占用,cpu占用,状态(http服务属于守护进程,只有用户请求进来时才会影响,state默认是sleeping状态)。

进程管理

因为有很多优秀的Node.js进程管理工具(例如pm2, nodemon, forever等),所以我们几乎不需要手动进行进程管理,而这些进程管理工具主要是提供以下功能:
1、提供进程管理的命令行(杀死/启动/重启/热重启);
2、进程守护(监听异常,进行热重启);
3、多进程;
4、负载均衡; 5、日志管理;
除了进程管理外,其他不在这里进行说明。为了对Node.js进程的进一步了解,我们可以尝试手动实现下进程管理相关的代码;

杀死/启动/重启/热重启 进程

一、杀死进程 process.exit(code) // code for listen event

二、启动进程 child_process.fork(); 三、重启进程

// 先fork,再exit() child_process.fork(); process.exit(code) // code for listen event 注:关于执行顺序后面有说明

热重启(滚动发布)

对于单机部署的node服务,热重启一般做法是滚动发布,一个个服务轮流进行重启。需要实现以下功能:
// 1 通知主进程不再进行任务派发(disconnect); workder.emit('disconnect') 2 等待10s(时间自己定,一般根据设定的连接超时时间来,避免仍在进行的任务被终止); sleep(10000) workder.kill() 3 关闭,重启服务; cluster.fork() 具体可参考源码

线程

线程(英語:thread)是操作系统能夠進行運算调度的最小單位。(取自维基百科) 我自己的理解是进程内任务调度单位,每个进程会根据特定算法进行任务调度执行任务。众所周知,javascrpt是单线程的,将调用的方法按栈的数据结构入栈/出栈进行调用,再加上event loop的异步任务队列组成。但实际上,javascript真的是单线程的吗?
我们还是用一个简单的例子看下:
``` import Koa from 'koa'

const app = new Koa()

app.use((ctx, next) => { ctx.body = 'hello world' }) app.listen(3002) // 通过端口获取pid, lsof -i:3002 // 通过pid获取线程信息 ps -M pid USER PID TT %CPU STAT PRI STIME UTIME COMMAND vb 45954 s012 0.0 S 31T 0:00.03 0:00.11 node index.js 45954 0.0 S 31T 0:00.00 0:00.00 45954 0.0 S 31T 0:00.00 0:00.01 45954 0.0 S 31T 0:00.00 0:00.01 45954 0.0 S 31T 0:00.00 0:00.01 45954 0.0 S 31T 0:00.00 0:00.01 45954 0.0 S 31T 0:00.00 0:00.00 45954 0.0 S 31T 0:00.00 0:00.00 45954 0.0 S 31T 0:00.00 0:00.00 45954 0.0 S 31T 0:00.00 0:00.00 45954 0.0 S 31T 0:00.00 0:00.00 可以看到其实一个Node.js进程开启了n个线程在处理任务,只是我们在实际开发过程中无法调用这些线程。 如果有一些复杂的计算,为了避免请求的阻塞,我们是否可以开启另外一个线程进行运算呢?答案是可以的,Node.js提供了worker_threads api给我们实现,e.g. // sum.js const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

if (isMainThread) { module.exports = function sumAsync(script) { return new Promise((resolve, reject) => { const worker = new Worker(__filename, { workerData: script }); worker.on('message', resolve); worker.on('error', reject); worker.on('exit', (code) => { if (code !== 0) reject(new Error(Worker stopped with exit code ${code})); }); }); }; } else { // 假装这是复杂计算 function sum() { return 1 + 2 + 3 + 4 + 5 } parentPort.postMessage(sum()); } // main.js const Koa = require('Koa') const app = new Koa() const sum = require('./sum')

app.use(async (ctx, next) => { let result = await sum() ctx.body = hello world ${result} }) app.listen(3002) 但这里有一些问题: 1、线程创建/通信对于开发者来说比较繁琐; 2、每次创建线程花销较大,需要创建线程池保存线程; 3、每次线程消费完后自动销毁线程(困扰了很久,例如在线程内做一个消息监听保持进程不被销毁);e.g. // 注意是在线程内 parentPort.on('message', (data) => { console.log(data) }) ```
所以一般需要通过插件实现,现在比较热门的插件piscina, threads等。

线程池

不管是进程池,线程池,连接池等,其实都是同样的设计,为了避免创建的性能消耗,提前创建多个资源,建立队列,队列在添加的时候触发,不断轮询有效的资源进行调用。
主要流程如下:
1、初始化创建线程池(默认1个线程);
2、任务进来后,封装为Promise, 将resolve,reject作为句柄传入队列(队列不断进行轮询直至所有任务完成);
3、任务完成后将结果通知主进程;
这是一个简单版本的线程池,还有一些问题需要注意:
1、这是一个实例,如果需要在多处使用,建议挂载到全局变量/全局可以访问的对象下,通过单例模式使用;
2、跟new worker()使用不太一样,new worker()接受的是可执行文件的路径,而此线程池接受的是线程需要执行的方法new pool(function);
源码

问题记录

1、杀死父进程是否也会杀死所有子进程?
可能不会,通过fork()/spawn()创建的进程,根据创建的参数detached决定是否会跟随父进程一起被杀(默认false,会跟随父进程一起被杀),如果设置为true,在父进程被杀后,会挂到系统跟节点上,继续执行;

2、关于重启,先fork(),在exit()。如果是同一个端口号,怎么保证执行顺序不会出错(fork的时候,端口仍被占用)?
fork()是异步的,exit是同步执行的,fork的执行时机比exit慢,所以端口不会仍被占用。

3、child_process.fork(), child_process.exec(), worker_threads等仅支持.js/.mjs/.cjs文件不支持.ts文件,如果是typescript环境下怎么处理?
通过ts-node的registry方法来处理,e.g. ``` import { WorkerOptions, Worker } from 'worker_threads'

const workerTs = (file: string, wkOpts: WorkerOptions) => { wkOpts.eval = true; if (!wkOpts.workerData) { wkOpts.workerData = {}; } wkOpts.workerData.__filename = file; return new Worker(const wk = require('worker_threads'); require('ts-node').register(); let file = wk.workerData.__filename; delete wk.workerData.__filename; require(file);, wkOpts ); } ```

参考文档

1 ps command: https://ss64.com/osx/ps.html
2 Node.js Child Processes: Everything you need to know: https://www.freecodecamp.org/news/node-js-child-processes-everything-you-need-to-know-e69498fe970a/
3 cluster是怎样开启多进程的,并且一个端口可以被多个 进程监听吗?: https://juejin.cn/post/6911456081336074253#heading-19
4 从源码分析Node的Cluster模块: https://juejin.cn/post/6844903764856406024
5 A complete guide to threads in Node.js: https://blog.logrocket.com/a-complete-guide-to-threads-in-node-js-4fa3898fe74f/