node多进程架构原理

859 阅读11分钟

一.概述

最近在开发涉及到了node服务端的搭建以及开发 在服务端开发时候遇到了这样的问题 登录信息明明设置了但还是会出现偶然拿不到的情况 后来发现是由于egg.js框架一般会启用相当于cpu核数的进程 这就导致了操作一份公共文件数据的冲突 同时在监听某些外部服务发送过来的消息的时候 回调函数执行了多次 导致对文件重复操作 出现这个问题还是由于对底层实现原理了解不深 同时也出于好奇就研究了一下多进程模型的实现原理

二、node进程与线程

1.Node线程与进程

我们常常听到有开发者说 “ Node.js 是单线程的”,那么 Node 确实是只有一个线程在运行吗?看一下下面代码的示例:

const fs = require('fs');
const http = require('http');
const arr = []
http.createServer(function(req, res) {
  res.writeHead(200, {'content-type': 'text/html'});
  res.end(fs.readFileSync(__dirname + '/index.html', 'utf-8'));

}).listen(3000, () => {
  console.log('listened 3000');
})
console.log('process id', process.pid);

创建一个http服务 并打印pid 通过命令top -pid 4399查看进程详情 123.jpeg 可以看到 #TH (threads 线程)数是7 说明node执行不是单线程的 那都用在哪里了?分析一下node系统架构图 node系统架构.jpeg

1.我们编写的JavaScript代码会经过V8引擎 再通过Node.js的Bindings(Node.js API),将任务派发到Libuv的事件循环中 2.我们js是脚本语言 因此是不能直接被识别的 需要浏览器引擎去通过一系列词法分析专程AST树在经过中间一系列优化传成机器码 这样js就能被跑起来了 为了使js在服务端环境运行 我们node引入了v8引擎 3.Js在执行一段代码时候 首先会在主进程创建一个执行栈 然后创建一个上下文push到执行栈 如果遇到函数 则会创建一个函数的上下文 push到站内 这也就是为什么子函数能访问到父函数所声明变量 当代码存在异步操作的时候 引擎将异步代码移出调用站放入一个事件队列里面 然后继续执行后续程序 eventloop尝试用libuv线程池里面取出一个空闲的线程去执行队列里面的操作 执行完毕获得结果通知主线程 主线程执行相关回调函数 并将线程归还给线程池

我们运行一个服务来实践一下

http.createServer(function(req, res) {
  res.writeHead(200, {'content-type': 'text/html'});
  res.end(fs.readFileSync(__dirname + '/index.html', 'utf-8'));
}).listen(3000, () => {
  console.log('listened 3000');
})
console.log('process id', process.pid);

打印出pid 终端运行top -pid 6444 image.png

我们看到 #TH (threads 线程) 这一列显示此进程中包含 7 个线程,说明 Node 进程中并非只有一个线程 再来看下加入I/O操作的情况

const fs = require('fs');
const http = require('http');
fs.readFile('./index.html', err => {
  if (err) {
    console.log(err);
    process.exit();
  } else {
    console.log(Date.now(), 'Read File I/O');
  }
});
console.log('process id', process.pid);

image.png libuv默认会分配四个线程处理I/O异步操作 因此线程新增了4个

所以说node是单线程也指的是主线程是单线程的。

总结一下一个node进程创建了哪些线程

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

2.Node多进程

当然有时候我们node.js处理的任务很多的情况那么主线程将长时间阻塞 光靠这一个进程是远远不够的 众所周知 Node.js中的JavaScript代码执行在单线程中 非常脆弱,一旦出现了未捕获的异常 那么整个应用就会崩溃 这在许多场景下,尤其是web应用中,是无法忍受的 因此 现在手机或者计算机都是多核cpu的 因此我们编程的时候要考虑如何利用多进程多线程来充分利用多核cpu的优势 从而优化我们的性能 接下来介绍node如何创建多进程 image.png

这个例子很简单 首先引入child_process包 fork子进程 然后通过ipc通信的方式 调用on以及send方法 与父进程通信 当然 node内部还实现了更快速创建多进程服务的方法

三、cluster

通常的解决方案,便是使用Node.js中自带的cluster模块,以master-worker模式启动多个应用实例 Node官方特地为了网络服务设计的 可以快速更加方便的创建一个多核能力的网络应用程序 我们父进程和子进程走一个ipc调用方式 进行全双方通信的 我们利用这个优势 有没有能力将http服务能力分发出去呢?我们先看下cluster使用方式

// index.js
const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {
      cluster.fork();
      cluster.fork();
      cluster.fork();

} else {
    require('./app.js');
};

const fs = require('fs');
const http = require('http');

http.createServer(function(req, res) {
  res.writeHead(200, {'content-type': 'text/html'});
  res.end(fs.readFileSync(__dirname + '/index.html', 'utf-8'));

}).listen(3000, () => {
  console.log('listened 3000');
})


三个进程的压测数据 image.png 单独运行一个进程的压测数据: image.png

QPS提升了将近三倍 为什么没有提升三倍呢 因为父进程与子进程通信有一定消耗 主进程也有一些阻塞 不过不影响优化效果

那具体启动多个子进程呢 其实我们可以通过os模块获取当前cpu多少核的 因此我们可以启动和cpu核数相当的子进程

const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {
  
    for(let i = 0; i < 1; i++) {
      cluster.fork();
    }   
} else {
    require('./app.js');
};

image.png QPS确实提升了不少

前面提到 每一个node进程有四个子线程完成事件循环 所以nodejs本身就有利用到其他核的cpu 不是一个完全单核的情况 fork子进程如果全部占满cpu 反而使得事件循环没有得到及时的处理 效果并不好 本身fork进程 内存空间复制一遍 造成成倍的内存消耗 但是cpu节省效果没有成倍增长 数量除以2 发现qps没有比全部占用cpu的qps降低了很多 但是内存节省了非常多 image.png

node多进程模型

我们尝试一种多进程模型 由 master 监听默认的 80 端口,用户的请求都打在 80 上,其他子进程监听一个别的端口,当父进程收到后往子进程监听的端口写数据,子进程来做处理。 这里看似可以实现,实则浪费了太多文件描述符,上面讲到了每个进程都有文件描述符表,而每个 socket 的读写也是基于文件描述符,客户段连接主进程用掉一个 主进程链接子进程用掉一个 操作系统的文件描述符是有限的

先来说一下文件描述符是什么 命令行/proc/xxx/fd打出来看下 image.png

操作系统将一切都抽象成文件 包括刚刚提到的套接字 文件描述符相当于对应表的指针

image.png

那如果每个进程都去监听80是不是会解决这个问题呢 但是会发现直接的监听最后只会有一个进程抢占端口成功,其他进程会抛出端口被占用的异常。为了解决这个问题,Node 用了另外一种架构模式

image.png

一开始依然是 master 进程监听 80,当收到用户请求之后,master 并不是直接把这些数据扔给 worker,而是在 80 端口接收到数据后,生成对应的 socket,再把该 socket 对应的文件描述符通过管道传给 worker,一个 socket 意味着服务端和客户端的一个数据通道,也就意味着 master 把跟客户端的数据通道传给了 worker。 在之后 master 停止监听 80port,因为已经把文件描述符给了 worker,之后 worker 直接监听这个套接字即可 那监听多个端口如何做到不报错的呢?

首先

  1. Node 对每个端口监听设置了SO_REUSEADRR,标示可以允许这个端口被多个进程监听

  2. 用这个的前提是每个监听这个端口的进程,监听的文件描述符要相同 master 在与 worker 通信的时候,每个子进程收到的文件描述符都是一样的,这个时候就是所有子进程监听相同的 socket 文件描述符,就可以实现多个进程监听同一个端口的目的了

cluster源码分析

首先找到listen方法 监听端口的操作应该就是在这里做的 image.png

listenCluster调用了getServer方法 找到对应模块 image.png image.png

_getServer: 真正的服务器监听是在子进程做的 一开始都是一些参数的判断 调用了send方法 act是一个行为名 看下send 里面引用了 sendHelper方法 sendHelper: process调用了send方法 调用send给父进程发了一个消息 那么去父进程查看这个消息的方法

image.png

前面都是参数的处理 主要使用了RoundRobinHandle 这个方法首先调用了net.createserver确实在主进程被调用 真正的端口号是在主进程监听的 看到这个端口号被传进来 然后他才回执行真正监听端口号的事情

image.png

看这个服务器实例 他监听到一个tcp链接进来之后在onconnect时候调用distribute方法 分布式方法 就是一个请求分发出去的动作 他把坚挺的句柄push到handles里面 子进程fork完成后放入free列表里面 任意一个tcp请求进来之后 从free列表里面负载均衡的挑选出一个子进程worker 调用handoff方法 向这个work进程发送消息 并吧这个tcp连接的handle发送进去 看下这个messsage 可以看到向子进程发送一个消息 这个消息的act是newconn 那看下子进程如何处理这个newconnde 子进程找到这个tcp的句柄handle 并带入子进程server实际的这个onconnecting方法 然后会写一个ack 往父进程发送了一个accept消息 代表他接受了这个请求了 sendhelper 有一个reply 发送消息并接受reply的过程相当于ipc调用 封装成异步函数的东西 如果请求接受了 调用handle.close 同时又调用了一个handleoff方法 也就是子进程池 有新的请求就从池里面拿一个空闲worker进程处理 没有空闲worker 会把handle暂存起来

image.png

子进程处理newconn事件 image.png

大家可能好奇free这个进程池何时加入的呢 其实在父进程代码里面new了一个RoundRobinHandle实例之后 就调用了这个add方法 将worker进程add到里面 image.png

image.png

小结

cluster模块为什么可以让多个子进程监听同一个端口呢?原因是master进程内部启动了一个TCP服务器,而真正监听端口的只有这个服务器,当来自前端的请求触发服务器的connection事件后,master会将对应的socket具柄发送给子进程 Node 对每个端口监听设置了SO_REUSEADRR,标示可以允许这个端口被多个进程监听 用这个的前提是每个监听这个端口的进程,监听的文件描述符要相同 master 在与 worker 通信的时候,每个子进程收到的文件描述符都是一样的,这个时候就是所有子进程监听相同的 socket 文件描述符,就可以实现多个进程监听同一个端口的目的了

四、node进程守护

场景

未捕获异常 当代码抛出了异常没有被捕获到时,进程将会退出

OOM、系统异常 而当一个进程出现异常导致 crash 或者 OOM 被系统杀死时,不像未捕获异常发生时我们还有机会让进程继续执行,只能够让当前进程直接退出,Master 立刻 fork 一个新的 Worker。

错误场景模拟举🌰

  1. 发生错误 我们监听到之后重新fork一个进程 使其优雅的退出
const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {
    for(let i = 0; i < os.cpus().length/2; i++) {

      cluster.fork();
    }   
    cluster.on('exit', () => {
       setTimeout(() => {
           cluster.fork()
       }, 5000)
    })
} else {
    require('./app.js');
    process.on('uncaughtException', (err) => {
      console.error(err);
      process.exit(1)
    })
};

image.png

  1. Node发生内存泄漏时候 在老生代垃圾回收时候 对象太多,太大 产生大量内存遍历时间 导致服务器越来越慢用户在此时,是有可能明显感到页面卡顿的 我们模拟内存泄漏并捕获处理

const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {

    for(let i = 0; i < 1; i++) {
      cluster.fork();
    }   
    cluster.on('exit', () => {
       setTimeout(() => {
           cluster.fork()
       }, 5000)
    })
} else {
    require('./app.js');
    process.on('uncaughtException', (err) => {
      console.error(err);
      process.exit(1)
    })
    setInterval(() => {
        console.log('内存', process.memoryUsage().rss);

        if(process.memoryUsage().rss>734003200) {
            console.log('OOM');
            process.exit(1)
        }
    }, 5000);
};

可看到 压测之后内存飙升 达到700M自动退出并创建一个新进程 image.png

总结

node对进程异常做的处理

  1. 关闭异常 Worker 进程所有的 TCP Server(将已有的连接快速断开,且不再接收新的连接),断开和 Master 的 IPC 通道,不再接受新的用户请求。

  2. Master 立刻 fork 一个新的 Worker 进程,保证work数量不变

  3. 异常 Worker 等待一段时间,处理完已经接受的请求后退出。

多进程带来的问题

  1. 多进程间资源访问冲突
  2. 有些工作其实不需要每个 Worker 都去做,如果都做,一来是浪费资源. 比如:每天凌晨 0 点,将当前日志文件按照日期进行重命名

egg.js使用Agent架构 解决了多进程带来的问题

比如长时间的消息监听 agent负责监听逻辑,但是回调函数不在agent中执行,而是写在worker里面。agent通过进程间通信通知其中空闲的一个worker去做回调处理

总结agent特点 何时使用agent?

  1. 长连接
  2. 只需要一个进程处理的任务

image.png

五、总结与规划

出现这个问题还是由于对底层实现原理了解不深就去开发 导致出现很多原本就可避免的问题 所以需要我们先熟悉框架机制后再进行业务开发,避免遇到奇怪的坑,浪费时间。