Node.js 进程/线程管理

背景

今天在看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。

扫描二维码关注公众号,回复: 13340402 查看本文章

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: ss64.com/osx/ps.html
2 Node.js Child Processes: Everything you need to know: www.freecodecamp.org/news/node-j…
3 cluster是怎样开启多进程的,并且一个端口可以被多个 进程监听吗?: juejin.cn/post/691145…
4 从源码分析Node的Cluster模块: juejin.cn/post/684490…
5 A complete guide to threads in Node.js: blog.logrocket.com/a-complete-…

猜你喜欢

转载自juejin.im/post/7034067763878445087