1000.png

进程(process)

一个进程就是一个程序的运行实例。 启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,这样的一个运行环境叫进程。

  • 进程中的任意一线程执行出错,都会导致整个进程的奔溃。
  • 当一个进程关闭后,操作系统会回收进程所占用的内存
  • 进程之间的内存相互隔离(需要 IPC 来进行通信)

    线程

    线程是程序执行中一个单一的顺序控制流,它存在于进程之中,是比进程更小的能独立运行的基本单位。

  • 线程之间共享进程中的数据

    单线程

    928697fd72094f54df234be3c428026f.webp
    一个 Node 进程包括以下线程:

  • 1个 JS 执行主线程

  • 1个 watchdog 监控线程用于调试信息
  • 1个 v8 task scheduler 线程用于调度任务优先级,加速延迟敏感任务执行
  • 4个 v8 线程,主要用来执行代码调优与 GC 等后台任务
  • 用于异步 I/O 的 libuv 线程池

    即 Node 并非只有一个线程,通常说 “Node 是单线程”是指 JS 的执行主线程只有一个

事件驱动/事件循环

Node 能支持较高并发的原因
2362.1540794088.jpg

  1. 执行栈(execution context stack):每个 Node.js 进程只有一个主线程在执行程序代码
  2. 事件队列(Event queue):存放用户的网络请求或其他异步操作
  3. 事件循环机制(Event Loop):从 Event queue 中不断取出事件,然后从线程池中分配空闲的线程去执行该事件。当有事件执行完毕后,会通知主线程,主线程执行回调,线程归还给线程池。

919e1043bf6faf05a43b824fd435c40c.png

Node.js 中的进程

进程创建

child_process 模块

  • spawn 以主命令加参数数组的形式创建一个子进程,子进程以流的形式返回 data 和 error 信息。
  • exec 是对 spawn 的封装,可直接传入命令行执行,以 callback 形式返回 error stdout stderr 信息
  • execFile 类似于 exec 函数,但默认不会创建命令行环境,将直接以传入的文件创建新的进程,性能略微优于 exec
  • fork 是 spawn 的特殊场景,只能用于创建 node 程序的子进程,默认会建立父子进程的 IPC 信道来传递消息

    spawn

    1. const spawn = require('child_process').spawn;
    2. const child = spawn('node', ['-v']);
    3. child.stdout.on('data', (data) => {
    4. console.log(`输出:${data}`);
    5. })
    6. child.stderr.on('data', (data) => {
    7. console.log(`错误输出:${data}`);
    8. })
    9. child.on('error', (err) => {
    10. console.error(err);
    11. })
    12. child.on('close', (code) => {
    13. console.log(`结束:${code}`)
    14. })

    exec

    ```javascript const exec = require(‘child_process’).exec;

exec(node -v, (error, stdout, stderr) => { console.log({ error, stdout, stderr }) // { error: null, stdout: ‘v8.5.0\n’, stderr: ‘’ } })

  1. <a name="o8PQP"></a>
  2. #### execFile
  3. ```javascript
  4. const execFile = require('child_process').execFile;
  5. execFile(`node`, ['-v'], (error, stdout, stderr) => {
  6. console.log({ error, stdout, stderr })
  7. // { error: null, stdout: 'v8.5.0\n', stderr: '' }
  8. })

fork

  1. const fork = require('child_process').fork;
  2. fork('./worker.js'); // fork 一个新的子进程

使用 fork 子进程充分利用 CPU 资源

fork_app.js

  1. const http = require('http');
  2. const fork = require('child_process').fork;
  3. const server = http.createServer((req, res) => {
  4. if(req.url == '/compute'){
  5. const compute = fork('./fork_compute.js');
  6. compute.send('开启一个新的子进程');
  7. // 当一个子进程使用 process.send() 发送消息时会触发 'message' 事件
  8. compute.on('message', sum => {
  9. res.end(`Sum is ${sum}`);
  10. compute.kill();
  11. });
  12. // 子进程监听到一些错误消息退出
  13. compute.on('close', (code, signal) => {
  14. console.log(`收到close事件,子进程收到信号 ${signal} 而终止,退出码 ${code}`);
  15. compute.kill();
  16. })
  17. }else{
  18. res.end(`ok`);
  19. }
  20. });
  21. server.listen(3000, '127.0.0.1', () => {
  22. console.log(`server started at http://127.0.0.1:${3000}`);
  23. });

fork_compute.js

  1. const computation = () => {
  2. let sum = 0;
  3. console.info('计算开始');
  4. console.time('计算耗时');
  5. for (let i = 0; i < 1e10; i++) {
  6. sum += i
  7. };
  8. console.info('计算结束');
  9. console.timeEnd('计算耗时');
  10. return sum;
  11. };
  12. process.on('message', msg => {
  13. console.log(msg, 'process.pid', process.pid); // 子进程id
  14. const sum = computation();
  15. // 如果Node.js进程是通过进程间通信产生的,那么,process.send()方法可以用来给父进程发送消息
  16. process.send(sum);
  17. })

进程通信

由于每个进程创建之后都有自己的独立地址空间。在 Node 中,父子进程可通过 IPC (Inter-Process Communication) 信道收发消息,实现进程之间资源共享访问,IPC 由 libuv 通过管道 pipe 实现。

progress_006.jpg

进程之间通过 process.send 发送消息,通过监听 message 事件接收消息。当一个进程发送消息时,会先序列化为字符串,送入 IPC 信道的一端,另一个进程在另一端接收消息内容,并且反序列化,因此我们可以在进程之间传递对象。

微信截图_20210304100846.png

CPU 密集型的任务交给子进程,子进程执行完成后通过 IPC 信道将结果发送给主进程

  1. // 主进程
  2. // main_process.js
  3. const { fork } = require('child_process');
  4. const child = fork('./fib.js'); // 创建子进程
  5. child.send({ num: 44 }); // 将任务执行数据通过信道发送给子进程
  6. child.on('message', message => {
  7. console.log('receive from child process, calculate result: ', message.data);
  8. child.kill();
  9. });
  10. child.on('exit', () => {
  11. console.log('child process exit');
  12. });
  13. setInterval(() => { // 主进程继续执行
  14. console.log('continue excute javascript code', new Date().getSeconds());
  15. }, 1000);
  1. // 子进程 fib.js
  2. // 接收主进程消息,计算斐波那契数列第 N 项,并发送结果给主进程
  3. // 计算斐波那契数列第 n 项
  4. function fib(num) {
  5. if (num === 0) return 0;
  6. if (num === 1) return 1;
  7. return fib(num - 2) + fib(num - 1);
  8. }
  9. process.on('message', msg => { // 获取主进程传递的计算数据
  10. console.log('child pid', process.pid);
  11. const { num } = msg;
  12. const data = fib(num);
  13. process.send({ data }); // 将计算结果发送主进程
  14. });
  15. // 收到 kill 信息,进程退出
  16. process.on('SIGHUP', function() {
  17. process.exit();
  18. });

守护(daemon)进程

运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。需要创建 daemon 进程就是为了保证中断创建该进程的父进程(ctrl+c)或者父进程执行完毕后并不影响 daemon 进程的执行。

实现步骤

  1. 父进程创建子进程,父进程退出,让子进程成为孤儿进程,ppid=1
  2. 通过 setsid 命令或函数在子进程中创建新的会话和进程组
  3. 设置当前目录
  4. 设置文件权限,并关闭父进程继承打开的 fd

所谓会话和进程组,则是在 linux 多任务多用户下的概念。不同会话的进程无法通过通信,因此父子进程相隔离。而执行 setsid 命令则让子进程有了新的特性:

  • 子进程脱离父进程所在的 session 控制,两者独立存在互不影响
  • 子进程脱离父进程所在的进程组
  • 子进程脱离原先的命令行终端,终端退出不影响子进程

    实现一

    在 linux 系统中,父进程创建出子进程,此时父进程若退出,此时子进程则变为孤儿进程,其 ppid 变为1,即成为 init 进程的子进程。在 node 环境下,如果不针对子进程的 stdio 做一些特殊处理父进程其实不会真正退出,而是直到子进程执行完毕后再退出。之所以出现这种情况是由于 node 创建子进程时默认会通过 pipe 方式将子进程的输出导流到父进程的 stream 中(childProcess.stdout、childProcess.stderr),提供在父进程中输出子进程消息的能力。
    因此,解决此种问题可给子进程的 stdio 重新赋值。
    默认“ctrl+c”触发 SIGINT 信号,父进程接受信号后发送给子进程,如果子进程存在 SIGINT 侦听函数,则会执行该函数,否则执行 exit 系统调用子进程退出。因此,如果要让子进程在接收到 SIGINT 信号不退出,只需要不作处理即可。 ```javascript // file: parent.js let cp = require(‘child_process’); const sp = cp.spawn(‘node’,[‘./c.js’],{ stdio: [process.stdin,process.stdout,process.stderr] });

setTimeout(()=>{console.log(‘parent out’)},5000);

// ———————

// file: c.js process.on(‘SIGINT’,function(){ console.log(‘child sigint’); });

setTimeout(()=>{ console.log(‘children exit’); },10000)

  1. <a name="k00aM"></a>
  2. ### 实现二
  3. **spawn **函数中的 detached 选项可以让 node 原生帮我们创建一个 daemon 进程,设置 datached 为 true 可以创建一个新的 session 和进程组,子进程的 pid 为新创建进程组的组 pid,这与 setsid 起到相同的作用。此时的子进程已经和其父进程属于两个 session,因此父进程的退出和中断信号不会传递给子进程,子进程不会接受到父进程的中断信号自然也不会退出。当父进程结束之后,子进程变为孤儿进程从而被 init 进程接收,ppid 设置为1。<br />在 parent.js 文件中设置了 `sp.unref()`函数,目的是“避免父进程等待子进程退出”。**这与 node 的事件循环有关,让父进程的事件循环排除对 ChildProcess 子进程对象的引用,可以使父进程单独退出。**
  4. ```javascript
  5. // file: parent.js
  6. let cp = require('child_process');
  7. const sp = cp.spawn('node',['./c.js'],{
  8. detached: true,
  9. stdio: [process.stdin,process.stdout,process.stdout]
  10. });
  11. sp.unref();
  12. setTimeout(()=>{console.log('parent out')},5000);
  13. // ----------------------
  14. // file: c.js
  15. setTimeout(()=>{
  16. console.log('children exit');
  17. },100000)

多进程架构模型

多进程架构解决了单进程、单线程无法充分利用系统多核 CPU 的问题

3236346695-5cc2782cd962f_articlex.png

句柄传递

句柄是一种可以用来标示资源的引用,它的内部包含了指向对象的文件描述符 比如句柄可以用来标示一个服务器 socket 对象等

v2-48b620d6d7831cea3303630da1c0eddc_r.jpg

通过句柄传递解决多进程端口占用冲突问题

  1. //master
  2. var fork = require('child_process').fork
  3. var server = require('net').createServer()
  4. server.listen(8888, () => { //master监听8888
  5. console.log('master on :', 8888)
  6. })
  7. var workers = {}
  8. for (var i = 0; i < 2; i++) {
  9. var worker = fork('./worker.js')
  10. worker.send('server', server)//发送句柄给worker
  11. worker[worker.pid] = worker
  12. console.log('worker create pid:', worker.pid)
  13. }
  14. //worker
  15. var http = require('http')
  16. var server = http.createServer((req, res) => {
  17. res.end('hahahaha')
  18. })//不监听
  19. process.on('message', (msg, tcp) => {
  20. if (msg === 'server') {
  21. const handler = tcp
  22. handler.on('connection', socket => {//代表有链接
  23. server.emit('connection', socket)//emit方法触发 worker服务器的connection
  24. })
  25. }
  26. })

集群模式

实现方案

  • 方案一:1 个 Node 实例开启多个端口,通过反向代理服务器向各端口服务进行转发
  • 方案二:1 个 Node 实例开启多个进程监听同一个端口,通过负载均衡技术分配请求(Master->Worker)

Cluster 模块采用第二种方案,cluster.fork() 本质上还是使用的 child_process.fork() 这个方法来创建的子进程
能够监听同一个端口的原因:

Master 进程创建一个 Socket 并绑定监听到该目标端口,通过与子进程之间建立 IPC 通道之后,通过调用子进程的 send 方法,将 Socket(链接句柄)传递过去

  1. // app.js
  2. const cluster = require('cluster');
  3. const http = require('http');
  4. const numCPUs = require('os').cpus().length;
  5. if (cluster.isMaster) {
  6. console.log(`Master 进程 ${process.pid} 正在运行`);
  7. for (let i = 0; i < numCPUs; i++) { // 衍生工作进程。
  8. cluster.fork();
  9. }
  10. cluster.on('exit', (worker, code, signal) => { console.log(`Worker ${worker.process.pid} 已退出`) });
  11. } else {
  12. http.createServer((req, res) => res.end(`你好世界 ${process.pid}`)).listen(8000);
  13. console.log(`Worker 进程 ${process.pid} 已启动`);
  14. }

如何对多个 Worker 进行请求分发

轮询策略

参考资料

  1. 浅析 Node 进程与线程
  2. [转]Node.js的线程和进程详解
  3. Nodejs探秘:深入理解单线程实现高并发原理
  4. 深入理解 Node.js 中的进程与线程
  5. 线程和进程
  6. node 中创建服务进程
  7. Node.js源码阅读:多进程架构的演进之路与eggjs多进程架构实践