Node深入浅出 章节总结(第九章 — 玩转进程) 完结篇

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/yolo0927/article/details/81224942

本章总结将结合个人搭建 egg 引入公司的一些实践来进行总结,希望能让大家了解到进程管理和集群分发的重要性。

阅读完本章你应该理解以下几点:

  • 为什么要使用多进程架构启动服务;
  • 经典的 Master-Worker(主从模式) 介绍到在 egg 中的应用;
  • 如何使用 child_process 模块与句柄(Handle)传递实现主从模式架构,从而实现启动多个服务监听一个端口;
  • 处理高并发场景的集群健壮性问题;
  • 使用 Cluster 模块 (本质是对 child_process 模块实现 Master-Work 架构过程的封装) 快速实现进程集群;

一、为什么要使用多进程架构启动服务
首先我们从两个关键性指标开始考虑

  1. CPU使用率;
  2. 内存占用率;

首先我们知道 js 是单线程的,所以 NodeJs 也不以外,启动服务时只能运用到单核,即一个 CPU,但如今大家的服务器或pc普遍都已经是 4 cpu 了,这就导致了另外 3 个 cpu 资源的浪费,这导致 node 的计算能力不弱都不行,而且单线程最大的问题还有个健壮性问题,一旦有一个错误没被捕获到,有可能就会导致整个进程的崩溃,而服务自然就断了。

在高并发的场景下,单 cpu 的计算量是远远不够的,即使 node 全异步的概念很擅长处理密集型 io,但也是寡不敌众啊,高并发下仍会导致响应产生延迟与耽误的情况,这时我们就要引入多进程架构来解决这个问题了,利用其它 cpu 同时进行计算处理 io,提高 cpu 的使用率,将并发请求分发到不同进程独立处理响应,从而解决高并发下延迟请求队列积累过长的问题。

多个 cpu 的有没有缺点?当然有,多个 cpu 同时计算是需要内存的,这个大家都懂,我们每次计算的数据结构都会在内存中进行处理,新生代会被立刻释放,需要存储的会被移动到老生代中,前面的章节已经为大家介绍过这个过程了,我就不废话了,所以榨干 cpu 的同时,我们一定要关注内存的占用率,在其中取得一个最佳平衡。这就像是算法一样,时间换空间 或者 空间换时间 选择一个,等价交换。

二、经典的 Master-Worker (主从模式) 架构
master-worker

由上图我们可以很清晰的看到整个架构的过程,创建一个主进程,然后子进程通过 fork 主进程生成,此时便解决了不充分利用 cpu 的问题了,但是高并发问题仍然没有解决哦,因为请求的分发并没有做处理。

在这个架构中,主进程是不负责具体的业务处理的,而是专门负责管理(socket 的分发与进程间的通信等)和调度(请求分发、进程间的通信分发)工作,是最稳定的一个进程,而子进程则是专门用于进行业务处理的,稳定性也是比较差的,但多进程最大的好处就是健壮性,因为其中一个子进程的崩溃并不会影响到其他子进程继续接受分发,从而保证了主进程服务的健壮性,而后续子进程崩溃后,我们可通过监听进程的全局异常,继而重启 fork 出新的子进程,崩溃多少次则重启多少次,但是重启是需要至少 30ms 的启动时间的,内存也是需要预留至少 10MB,因为每个子进程都是独立的 V8 实例。所以我们不能短时间内频繁重启,而这些就是后话了,后面再详细向大家介绍。

三、如何使用 child_process 模块与句柄(Handle)传递实现实现主从模式架构,从而实现启动多个服务监听一个端口

通过 node 建立过服务的童鞋都应该知道,http 服务我们直接使用 http.createServer 即可创建并直接监听某个端口,但是端口被占用的情况下,如果仍然监听就会引起 EADDRINUSE 异常,那么进程间是如何知道判断我们监听了同一端口呢,答案就是 socket 的套接字,首先大家都需要知道 http 模块本质上是 net 模块封装而来,本质上是 tcp 协议封装的 http 协议,其中仍然是 socket 套接字,当监听同一端口时,tcp 服务发现此时 socket 套接字的文件描述符是不同的,此时就异常了,所以如果我们能够使 2 个服务使用同一个 socket 套接字,那么文件描述符就相同了,这就解决了这个问题,由此我们就引申除了需要解决的 2 个问题

  1. 所有子进程如何与父进程一样都是用一个 socket 套接字?
  2. 子进程如何监听到父进程的 socket 的连接事件从而接收到父进程所分发的内容最传递给子进程启动的 http 服务呢?

A1:在进程间通信中通过 IPC 管道将句柄发送 socket 给子进程,从而使子进程也可以对这个 tcp 服务的 connection 进行监听;
A2:node 子进程通过监听 message 事件便可接受到父进程所 send 的句柄,在接受到 socket 句柄后,便可监听到父进程 tcp 服务的 connection 事件了,当父进程的 tcp 服务被访问时,子进程监测并将本次的 socket 通过 emit 触发 http.server 类 的 connection 事件;

在解决方案中,我们引申出了几个具体名词和方法的解析,在以下会对其进行详解,并最终放上实现过程的代码;

  1. 进程间如何进行普通通信?
  2. 进程间如何发送 socket 句柄,句柄又是什么东西,是怎么发送的?
  3. http.server 类继承与 net.server 类,理论上具有 net 所有通过 EventEmitter 类所定义事件;

问题1:进程间如何进行普通通信

// parent.js
var cp = require('child_process')
var child = cp.fork('./child.js')

child.send('hello world')

process.on('message', function (m) {
  console.log('parent 进程已接收到 child 进程的' + m)
})

// child.js
process.on('message', function (m) {
  console.log('child 进程已接收到 parent 进程的' + m)
})

从 demo 中我们可以看出父进程运行时会复制启动一个新的子进程,并且我们在父进程中向子进程发送了 hello world,当我们运行 node parent.js 时,会发现命令窗打印出了 ‘child 进程已接收到 parent 进程的hello world’ ,此时我们已经实现了父进程 => 子进程的单向通信了。

ps: emmmmmm,我就知道会有童鞋尝试在子进程再次 fork parent.js 然后发送消息,之后再 parent.js 中打印出来= =你以为这是双向通信吗,骚年,这是 fork 了一个新的进程再输出到控制台的而已,不信你自己启动 node 后查看过滤出正在运行的 node 进程列表,正确操作应是在父进程中取出 fork 的子进程再次监听该子进程的 message 事件,子进程中直接使用 process.send 即可,这样就实现了父子进程的双向通信了 父 => 子 => 父。

// parent.js
var cp = require('child_process')
var child = cp.fork('./child.js')

child.send('hello world')

child.on('message', function (m) {
  console.log('parent 进程已接收到 child 进程的' + m)
})

// child.js
process.on('message', function (m) {
  console.log('child 进程已接收到 parent 进程的' + m)
})

process.send('hello, I"m child process')

问题2:进程间如何发送 socket 句柄,句柄又是什么东西,是怎么发送的?
其中涉及到 2 个点,IPC 通道与 句柄(handle)
IPC通道:IPC全称为 Inter-Process Communication ,即进程间通信,在 node 中是利用 pipe 实现的,看到 pipe 大家应该会想到我们读取与写入文件时的流操作也利用过管道,联想是正确的,因为在进程通信的过程中也确实这么做的,在父进程 send 前,node 会自动将信息通过 JSON.stringify包装起来后才会 send ,之后写入 IPC 通道,通过 IPC 通道到达子进程前进行 JSON.parse 后 got 到这次发送的消息给子进程接收,所以我们可以将 IPC 通道理解为消息中间管道。

句柄:我们在问题 1 中进行的是普通信息的传递,但通过文档我们可以看到其实 send 是有第二个参数的,第二个参数便是用来发送句柄的,句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符,所以我们发送的句柄本质上就发送文件描述符,上面我们有提到,端口就是因为 socket 套接字的文件描述符而出现端口被占用的异常,而这里发送的句柄便刚好解决了这个问题,句柄有以下几种,当发送的不为以下几种时,子进程是无法接收到的。

  • net.Socket TCP套接字
  • net.Server TCP服务器,再 TCP 服务上的应用层都可以继承它,如 http.server 类
  • net.Native C++ 层面的 TCP 套接字或 IPC 管道
  • dgram.Socket UDP 套接字
  • dgram.Native C++ 层面的 UDP 套接字

上面我们提到过,只要子进程能接收到句柄,便可以使用父进程的 socket 监听 connection 事件从而去 emit 到 http 服务的 request 事件(注意是 request 事件,不是 http.clientRequest 类,也不是 http.IncomingMessage 类,而是触发 http.server 的请求事件,即 http.createServer 建立服务后自动监听的事件),并获取本次请求的 http.IncomingMessage 与 http.serverResponse 类所挂载的引用,下面是 node 使用 child_process 模块后一个端口同时启动多个服务的 demo

// master.js
var fork = require('child_process').fork
var cpus = require('os').cpus()

var server = require('net').createServer()
server.listen(1337)

var workers = {}
var createWorker = function () {
  var worker = fork(__dirname + '/worker.js')

  // 监听子进程的退出,若退出则重新创建一个新的子进程
  worker.on('exit', function () {
    console.log('Worker ' + worker.pid + ' exited.')
    delete workers[worker.pid]
    createWorker()
  })

  // 发送句柄
  worker.send('server', server)
  workers[worker.pid] = worker
  console.log('Create worker. pid: ' + worker.pid)
}
for (var i = 0; i < cpus.length; i++) {
  createWorker()
}

// 监测主进程的退出,当主进程退出时,主动退出所有子进程
process.on('exit', function () {
  for (var pid in workers) {
    workers[pid].kill()
  }
})

// worker.js
var http = require('http')
var server = http.createServer(function (req, res) {
  res.writeHead(200, { 'Content-Type': 'text/plain' })
  res.end('handle by child, pid is ' + process.pid + '\n')
})

var worker
process.on('message', function (m, tcp) {
  if (m === 'server') {
    worker = tcp
    worker.on('connection', function (socket) {
      server.emit('connection', socket)
    })
  }
})

process.on('uncaughtException', function (err) {
  // 停止接受新的连接
  worker.close(function () {
    // 所有已有连接断开后,退出进程
    process.exit(1)
  })
})

在以上 demo 中,我们除了使用 tcp 服务代理 http 服务以外,还为了多进程的健壮性做到了以下:

  1. 子进程因某些异常而导致的退出重启,可见 master.js 中 work 的 exit 事件回调;
  2. 当主进程 master 异常退出时,自动退出所有子进程,可见 master.js 中对 process exit 事件的监听;
  3. 在子进程中监听当前子进程的全局异常事件,若出现异常则手动关闭子进程传递过来句柄的 tcp 套接字,并在完成后退出子进程,触发父进程中子进程的 exit 事件达到退出现有子进程重启新的子进程的效果;

注意:windows 系统下会出现 curl "http://127.0.0.1:1337" 子进程 request 事件回调中一直打印的为一个 pid 的情况,而在 macOs 或 linux 等有 unix 内核的系统中会是创建的多个子进程中的随机一个 pid,这是 windows 下才会产生的问题,我们正常应是 unix 内核中的情况,因为 node 默认提供的机制是采用操作系统的抢占式策略,闲着的进程对到来的请求进行抢夺,谁抢到算谁的。

书中重点提到:这种抢占式策略是根据当前进程的繁忙度来进行抢占的,繁忙度较低的进程会先抢占到请求,而繁忙是由 CPU 与 I/O 两部分构成,即我们一直提到了 CPU 占用率与 内存 使用率,而本处定义影响抢占的确只是 cpu 的繁忙度,这就导致了,抢占到的进程有可能是 I/O 繁忙,而 cpu 却空闲的情况,这导致该进程内存不足而使得后续可能无法继续维持计算所需的内存(即高并发场景下容易造成延迟现象,因为没有内存供给 I/O 处理),这也引起了负载不均衡的现象,此时我们必须想一个新的抢占策略来实现 cpu 与 I/O 的均衡分配,而在node V0.11 增加的 Round-Robin(轮叫调用)便实现了这点,cluster 模块中启用它的方式为(一般我们都默认启动):
cluster.schedulingPolicy = cluster.SCHED_RR

四、处理高并发场景多进程集群健壮性的问题
关于集群的健壮性,有几点需要注意的

  • 进程异常是否能够监控记录并作出应对;
  • 进程崩溃时能否保证中断本次连接后重启出新的子进程继续工作,保证响应不会一直延迟,而是直接抛出错误;
  • 子进程重启过于频繁时是是否能预警;

对于以上几点,个人总结就是小错提交日志自动重启,大错预警及时止损,在书中提供了这么几个思路。

  1. 使用监控进程异常的 'uncaughtException' 事件,本事件为当前进程的全局异常监控,当监控到异常时像 master 主进程发送自杀指令并 ‘自杀’,由 master 统一管理进程的重启,分别对应的代码为:

    // worker.js
    process.on('uncaughtException', function (err) {
      // 错误日志记录
      // logger.error(err)
      process.send({ act: 'suicide' })
      // 停止接受新的连接
      worker.close(function () {
        // 所有已有连接断开后,退出进程
        process.exit(1)
      })
      // 为了防止 tcp 长连接断开时间过长,此处设置一个超时机制,超过一定时间如果当前 process 还存在,则手动退出
      setTimeout(() => {
        process.exit(1)
      }, 5000)
    })
    
    // master.js 在创建进程的函数 createWorker 中插入监听事件,当监听到时,同步进行新子进程的创建与旧子进程的连接中断与进程退出
    worker.on('message', function (message) {
      if (message.act === 'suicide') {
        createWorker()
      }
    })

    这 2 个事件的监控已经处理完了日志监控与中断连接退出异常进程并重启的过程,原理很简单,进程内全局监控异常,有异常时直接中断连接,保证所有连接中断后退出进程,在这个过程进行的同时,通知 master 创建新进程,两个任务同步进行以保证负载均衡不会被打破,新的请求仍可以立刻分发到新的子进程。
    需注意文档已经有说明不要尝试恢复旧进程的应用了,所以书中选择直接退出旧进程,创建新进程
    正确地使用 uncaughtException

  2. 如果遇到业务异常导致子进程频繁无限重启该怎么办?
    这种情况的发生比较少,一般极有可能是业务代码出错,否则不会无限频繁报异常,单纯的内存分配不足只会造成延迟等待,如果cpu 负载运转率过高,可能系统已经自动 kill 掉主进程了,子进程会全部被正常杀死(demo 中可看出非异常退出不会造成自动重启,而是直接 emit 进程的 exit 事件),这都是正常的处理情况,此时收到预警的运维童鞋重启服务即可。
    但如果是业务模块的错误导致重启频率过高,这时我们查看日志发现一直是一个异常,且因为频繁重启查过限定后导致的主进程手动触发退出,就要注意了,下面为完整的 createWorker 与判断重启频率函数 isTooFrequently

// master.js
var fork = require('child_process').fork
var cpus = require('os').cpus()

var server = require('net').createServer()
server.listen(1337)

var limit = 10;
var during = 60000;
var restart = [];
var isTooFrequently = function () {
  var time = Date.now()
  var length = restart.push(time)
  // restart 数组永远只取最后限定条数
  if (length > limit) {
    restart = restart.slice(-1 * limit)
  }
  // 最后的重启次数大于或等于限定次数且最后一次重启距离 limit 第一条的重启时间如果小于单位时间,则代表单位时间内重启太频繁了
  return restart.length >= limit && restart[restart.length - 1] - restart[0] < during
}

var workers = {}
var createWorker = function () {
  // 检测是否重启太过频繁
  if (isTooFrequently()) {
    process.emit('giveup', length, during)
    return ;
  }

  var worker = fork(__dirname + '/worker.js')

  worker.on('message', function (message) {
    if (message.act === 'suicide') {
      createWorker()
    }
  })

  // 监听子进程的退出,若退出则重新创建一个新的子进程
  worker.on('exit', function () {
    console.log('Worker ' + worker.pid + ' exited.')
    delete workers[worker.pid]
    createWorker()
  })

  // 发送句柄
  worker.send('server', server)
  workers[worker.pid] = worker
  console.log('Create worker. pid: ' + worker.pid)
}

for (var i = 0; i < cpus.length; i++) {
  createWorker()
}

process.on('giveup', function (count, time) {
  // logger.error(`子进程重启过于频繁自动退出主进程,请紧急处理,${time}ms内重启次数为: ${count}`)
  process.close(function () {
    process.exit(1)
  })
  setTimeout(() => {
    process.exit(1)
  }, 5000)
})

// 监测主进程的退出,当主进程退出时,主动退出所有子进程
process.on('exit', function () {
  for (var pid in workers) {
    workers[pid].kill()
  }
})

每次创建新的子进程时,会使用 isTooFrequently去做判断,判断是否频率过高需要满足 2 个条件

  • 重启的次数超过限定次数(存在重启次数早已大于限定,但时间频率未达到的情况,所以我们可以看到 restart 每次都会取最后 limit 的位长度,保证了未到达重启次数限定时不可能算 “太频繁”)
  • 在超过限定次数的前提下,且满足 restart 数组中最后一位 - 第一位 的值小于初始设定的单位时间值 during(restart 数组每次 push 的是重启时的时间戳,时间戳相减即可得到 limit 次重启的单位时间)

如果符合我们规定的频率过高设定,则触发自定义的 giveup 事件,书中代码未监听 giveup 事件,结果上我们应该监听 giveup 事件并写入预警函数以及特别严重的错误提示,告知运维童鞋紧急处理,在本总结上面的 demo 中我已加入对 giveup 事件的监听,并设定等待连接中断的限定时间,process 是 EventEmitter 的实例,所以大家大可随意使用订阅发布模式的事件订阅与发布,别再小白的问为什么可以自定义订阅事件名了~~~。

五、cluster模块的应用
在使用 child_process 模块实现主从模式的单机集群架构时,我们会感觉比较繁琐,要反复进行主从进程间的通信,并且订阅很多自定义事件去做如异常监控、退出重启等功能日志,还要自己创建 tcp 服务,监听与发送句柄的繁杂的操作,对于一个经验较少的工程师来说是很容易搞错其中一步的,所以 Node 从 V0.8 版本开始新增了 Cluster 模块来解决这个问题,egg 也是使用 Cluster 模块创建的主从模式,并在 master => worker 中间加了一层代理层用于一些公共的业务处理,以求不用让每个子进程都去运行重复代码。

以下是书中的两种方式,书中不太推崇官方文档的创建方式,认为官方的创建方式比较繁琐,需要使用者自己判断是 master 还是 worker 进程,但我个人反而觉得官方的方式比较清晰、明了。

// 书中朴老师比较推崇的使用 setupMaster 的方式
var cluster = require('cluster')

cluster.setupMaster({
  exec: 'worker.js'
})

var cpus = require('os').cpus()
for(var i = 0; i < cpus.length; i++) {
  cluster.fork()
}

// 官方的方式
var cluster = require('cluster')
var http = require('http')
var numCPUs = require('os').cpus().length

if (cluster.isMaster) {
  for (var i = 0; i< numCPUs; i++) {
    cluster.fork()
  }

  cluster.on('exit', function (worker, code, signal) {
    console.log(`worker ${worker.process.pid} died`)
  })
} else {
  console.log('current process pid is: ' + process.pid)
  http.createServer(function (req, res) {
    res.writeHead(200)
    console.log(process.pid)
    res.end('hello world\n')
  }).listen(8000)
}

比较两种方式的区别是,官方的方式需要我们自行判断当前 cluster 创建的进程是 master 还是 worker,然后进行不同的事件监听,个人认为官方的方式更让我觉得清晰,比较贴近原本 child_process 模块与创建 tcp 服务发送句柄的原理,会感受到整个过程是 “可控的” ,而朴老师的方式虽然非常简洁,但是却容易让新手产生摸不着门路的困惑感。

基本 egg master => agent => worker 这一套多进程架构就跟我们上面介绍的创建 tcp 服务并使用 child_process fork 子进程的思想是一样的,只是换成了 Cluster 模块来实现,免去了繁琐的创建 tcp 服务与发送句柄这个过程与监控 process exit 等过程,继而全部由 cluster 模块的 api 与事件来实现,大家看完本章再去看 egg 的这节文档就会发现文档中写的东西都非常清晰了。

egg —– 多进程模型和进程间通讯

猜你喜欢

转载自blog.csdn.net/yolo0927/article/details/81224942