Skip to content

Egg多进程

1. Nodejs多进程【Cluster】

我们常说js是单线的,其实指的是js的主进程(Master)是单线程,默认情况下,Nodejs进程只会运行在一个cpu上,那么若是 Node.js 来做 Web Server,就无法享受到多核运算的好处。

对于Nodejs多进程, Nodejs官方提供了Cluster模块

单个 Node.js 实例在单线程环境下运行。为了更好地利用多核环境,用户有时希望启动一批 Node.js 进程用于加载。 集群化模块使得你很方便地创建子进程,以便于在服务端口之间共享。

1.1 cluster是什么?

借助Egg官方的描述:

简单的说,

  • 在服务器上同时启动多个进程。
  • 每个进程里都跑的是同一份源代码(好比把以前一个进程的工作分给多个进程去做)。
  • 更神奇的是,这些进程可以同时监听一个端口(具体原理推荐阅读 @DavidCai1993 这篇 Cluster 实现原理)。

其中:

  • 负责启动其他进程的叫做 Master 进程,他好比是个『包工头』,不做具体的工作,只负责启动其他进程。
  • 其他被启动的叫 Worker 进程,顾名思义就是干活的『工人』。它们接收请求,对外提供服务。
  • Worker 进程的数量一般根据服务器的 CPU 核数来定,这样就可以完美利用多核资源。

1.2 用Cluster模块创建一个Nodejs多进程应用

image.png

js
const cluster = require("cluster");
const http = require("http");
const numCPUs = require("os").cpus().length; // 获取CPU核数 (你的电脑是几核的)

if (cluster.isMaster) {
  // 如果进程是主进程,就根据cpu核数,从主进程产生同等数量的新的工作进程。
  // 以利用多核的优势,每一个进程上可以跑一套代码
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  // 主进程监听进程退出事件
  cluster.on("exit", function (worker, code, signal) {
    console.log("worker " + worker.process.pid + " died");
  });
} else {
  // 工作进程可以共享任何TCP连接
  // 在这里,它是一个HTTP服务器
  http
    .createServer(function (req, res) {
      res.writeHead(200);
      res.end("hello world\n");
    })
    .listen(8000);
  console.log("serve runing port 8000");
}

这仅仅是最基本的多进程应用例子,它没有进程之间的通信,非常简陋,非常基础

2. Egg多进程

Egg作为一个企业级的解决方案,当然考虑的东西也很全面,下面来详细介绍

2.1 egg多进程模型

一个egg应用中,有这几种进程: Master进程:一个应用仅一个 Agent进程:一个应用仅一个 Worker进程:每个应用根据Cpu核数来,几核就会有几个 Worker 进程

他们之间的关系如下:

                +--------+          +-------+
                | Master |<-------->| Agent |
                +--------+          +-------+
               ^    ^    ^
              /     |      \
             /      |       \
            /       |        \
           v        v         v
+----------+   +----------+   +----------+
| Worker 1 |   | Worker 2 |   | Worker 3 |  ....
+----------+   +----------+   +----------+

2.1.1 Master进程

Master 进程承担了进程管理的工作(类似 pm2),不运行任何业务代码,我们只需要运行起一个 Master 进程它就会帮我们搞定所有的 Worker、Agent 进程的初始化以及重启等工作了。

Master 进程的稳定性是极高的,线上运行时我们只需要通过 egg-scripts 后台运行通过 egg.startCluster 启动的 Master 进程就可以了,不再需要使用 pm2 等进程守护模块。

sh
egg-scripts start --daemon

2.1.2 Agent进程

若是仅仅只是Master fork出一些workers进程,那么会出现一个问题,所有的工作,所有的worker进程都会参与,这显然是由问题的,例如:生产环境的日志文件我们一般会按照日期进行归档,这只需要一各进程来完成就行,若多个worker参与,不仅浪费资源,还很可能会乱套,所以,对于这一类后台运行的逻辑,我们希望将它们放到一个单独的进程上去执行,这个进程就叫 Agent Worker,简称 Agent

Agent进程在egg应用中充当的角色是Master 给其他 Worker 请的一个『秘书』,它不对外提供服务,只给 App Worker 打工,专门处理一些公共事务。

在大部分情况下,我们在写业务代码的时候完全不用考虑 Agent 进程的存在,但是当我们遇到一些场景,只想让代码运行在一个进程上的时候,Agent 进程就到了发挥作用的时候了

由于 Agent 只有一个,而且会负责许多维持连接的脏活累活,因此它不能轻易挂掉和重启,所以 Agent 进程在监听到未捕获异常时不会退出,但是会打印出错误日志,我们需要对日志中的未捕获异常提高警惕。

可以在应用插件根目录下的 agent.js 中实现你自己的逻辑(和启动自定义 用法类似,只是入口参数是 agent 对象)

js
// agent.js
module.exports = agent => {
  // 在这里写你的初始化逻辑

  // 也可以通过 messenger 对象发送消息给 App Worker
  // 但需要等待 App Worker 启动成功后才能发送,不然很可能丢失
  agent.messenger.on('egg-ready', () => {
    const data = { ... };
    agent.messenger.sendToApp('xxx_action', data);
  });
};
js
// app.js
module.exports = (app) => {
  app.messenger.on('xxx_action', (data) => {
    // ...
  });
};

2.1.3 Worker进程

在egg中,Worker进程就是用来处理真正的用户请求定时任务的进程,我们的业务代码都是跑在一个个Worker进程上面。

而 Egg 的定时任务也提供了只让一个 Worker 进程运行的能力,所以能够通过定时任务解决的问题就不要放到 Agent 上执行。

Worker 运行的是业务代码,相对会比 Agent 和 Master 进程上运行的代码复杂度更高,稳定性也低一点,当 Worker 进程异常退出时,Master 进程会重启一个 Worker 进程。

2.1.4 三种进程对比

类型进程数量作用稳定性是否运行业务代码
Master1进程管理,进程间消息转发非常高
Agent1后台运行工作(长连接客户端)少量
Worker一般设置为 CPU 核数执行业务代码一般

2.2 Egg进程启动顺序

首先来看一下官方给出的框架的启动时序图:

+---------+           +---------+          +---------+
|  Master |           |  Agent  |          |  Worker |
+---------+           +----+----+          +----+----+
     |      fork agent     |                    |
     +-------------------->|                    |
     |      agent ready    |                    |
     |<--------------------+                    |
     |                     |     fork worker    |
     +----------------------------------------->|
     |     worker ready    |                    |
     |<-----------------------------------------+
     |      Egg ready      |                    |
     +-------------------->|                    |
     |      Egg ready      |                    |
     +----------------------------------------->|
  1. Master 启动后先 fork Agent 进程
  2. Agent 初始化成功后,通过 IPC 通道通知 Master
  3. Master 再 fork 多个 App Worker
  4. App Worker 初始化成功,通知 Master
  5. 所有的进程初始化成功后,Master 通知 Agent 和 Worker 应用启动成功

另外,关于 Agent Worker 还有几点需要注意的是:

  1. 由于 App Worker 依赖于 Agent,所以必须等 Agent 初始化完成后才能 fork App Worker
  2. Agent 虽然是 App Worker 的『小秘』,但是业务相关的工作不应该放到 Agent 上去做,不然把她累垮了就不好了
  3. 由于 Agent 的特殊定位,我们应该保证它相对稳定。当它发生未捕获异常,框架不会像 App Worker 一样让他退出重启,而是记录异常日志、报警等待人工处理
  4. Agent 和普通 App Worker 挂载的 API 不完全一样,如何识别差异可查看框架文档

2.3 Egg怎么做进程守护的?

在Nodejs中,进程守护用的最多的恐怕就是pm2了,进程守护,也就是说当进程因为一些原因挂掉后,进程守护的工具可以自动帮助我们重启进程,这样可以提高应用的稳定性,健壮性。

健壮性(又叫鲁棒性)是企业级应用必须考虑的问题,除了程序本身代码质量要保证,框架层面也需要提供相应的『兜底』机制保证极端情况下应用的可用性。

2.3.1 nodejs进程退出分类

nodejs进程退出可以分为两类:

  1. 未捕获异常导致进程退出
  2. OOM、系统异常导致进程退出

1)未捕获异常导致进程退出

当代码抛出了异常没有被捕获到时,进程将会退出,此时 Node.js 提供了 process.on('uncaughtException', handler) 接口来捕获它。

uncaughtException 事件为未捕获异常事件,uncaughtexception事件说明

但是当一个 Worker 进程遇到 未捕获的异常 时,它已经处于一个不确定状态,此时我们应该让这个进程优雅退出:

  1. 关闭异常 Worker 进程所有的 TCP Server(将已有的连接快速断开,且不再接收新的连接),断开和 MasterIPC 通道,不再接受新的用户请求。
  2. Master 立刻 fork 一个新的 Worker 进程,保证在线的『工人(Worker进程)』总数不变。
  3. 异常 Worker 等待一段时间,处理完已经接受的请求后退出。

未捕获异常进程守护流程图如下:

+---------+                 +---------+
|  Worker |                 |  Master |
+---------+                 +----+----+
     | uncaughtException         |
     +------------+              |
     |            |              |                   +---------+
     | <----------+              |                   |  Worker |
     |                           |                   +----+----+
     |        disconnect         |   fork a new worker    |
     +-------------------------> + ---------------------> |
     |         wait...           |                        |
     |          exit             |                        |
     +-------------------------> |                        |
     |                           |                        |
    die                          |                        |
                                 |                        |

2)OOM、系统异常 导致进程退出

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

在egg框架里,我们采用 gracefulegg-cluster 两个模块配合实现上面的逻辑。这套方案已在阿里巴巴和蚂蚁金服的生产环境广泛部署,且经受过『双 11』大促的考验,所以是相对稳定和靠谱的。

3. 进程之间的通信

3.1 Nodejs进程之间通信

虽然每个 Worker 进程是相对独立的,但是它们之间始终还是需要通讯的,叫进程间通讯(IPC)。

下面是 Node.js 官方提供的一段示例代码

js
const cluster = require('cluster');

if (cluster.isMaster) {
  const worker = cluster.fork(); // 1. 主进程fork出一个worker进程
  worker.send('hi there'); // 2. fork出的worke进程通过send发送一个消息给其他进程
  worker.on('message', (msg) => {
    // 4. 最终worker进程监听到了msg
    console.log(`msg: ${msg} from worker#${worker.id}`);
  });
} else if (cluster.isWorker) {
// 3. worker进程监听message事件,后,再将msg转发
  process.on('message', (msg) => {
    process.send(msg);
  });
}

clusterIPC 通道只存在于 MasterWorker/Agent 之间,WorkerAgent 进程互相间是没有的。那么 Worker 之间想通讯该怎么办呢?是的,通过 Master 来转发,如下图所示:

广播消息: agent => all workers
                  +--------+          +-------+
                  | Master |<---------| Agent |
                  +--------+          +-------+
                 /    |     \
                /     |      \
               /      |       \
              /       |        \
             v        v         v
  +----------+   +----------+   +----------+
  | Worker 1 |   | Worker 2 |   | Worker 3 |
  +----------+   +----------+   +----------+

指定接收方: one worker => another worker
                  +--------+          +-------+
                  | Master |----------| Agent |
                  +--------+          +-------+
                 ^    |
     send to    /     |
    worker 2   /      |
              /       |
             /        v
  +----------+   +----------+   +----------+
  | Worker 1 |   | Worker 2 |   | Worker 3 |
  +----------+   +----------+   +----------+

3.2 Egg进程通信方式一

为了方便调用,egg官方封装了一个 messenger 对象挂在 app / agent 实例上,提供一系列友好的 API。

前提: 需要等 egg-ready 消息之后才能发送消息。只有在 Master 确认所有的 Agent 进程和 Worker 进程都已经成功启动(并 ready)之后,才会通过 messenger 发送 egg-ready 消息给所有的 AgentWorker,告知一切准备就绪,IPC 通道可以开始使用了。

1)发送API

  • app.messenger.broadcast(action, data):发送给所有的 agent / app 进程(包括自己)
  • app.messenger.sendToApp(action, data): 发送给所有的 app 进程 -- 在 app 上调用该方法会发送给自己和其他的 app 进程 -- 在 agent 上调用该方法会发送给所有的 app 进程
  • app.messenger.sendToAgent(action, data): 发送给 agent 进程 -- 在 app 上调用该方法会发送给 agent 进程 -- 在 agent 上调用该方法会发送给 agent 自己
  • agent.messenger.sendRandom(action, data): -- app 上没有该方法(现在 Egg 的实现是等同于 sentToAgent) -- agent 会随机发送消息给一个 app 进程(由 master 来控制发送给谁)
  • app.messenger.sendTo(pid, action, data): 发送给指定进程
js
// app.js
module.exports = (app) => {
  // 注意,只有在 egg-ready 事件拿到之后才能发送消息
  app.messenger.once('egg-ready', () => {
    app.messenger.sendToAgent('agent-event', { foo: 'bar' });
    app.messenger.sendToApp('app-event', { foo: 'bar' });
  });
};

注意:上面所有 app.messenger 上的方法都可以在 agent.messenger 上使用。

2)接收API

messenger 上监听对应的 action 事件,就可以收到其他进程发送来的信息了。

js
app.messenger.on(action, (data) => {
  // process data
});
app.messenger.once(action, (data) => {
  // process data
});

agent 上的 messenger 接收消息的用法和 app 上一致。

3) messenger 传递数据的缺点:

  • 为了尽可能的复用长连接,(因为它们对于服务端来说是非常宝贵的资源),我们会把它放到 Agent 进程里维护,然后通过 messenger 将数据传递给各个 Worker。这种做法是可行的,但是往往需要写大量代码去封装接口和实现数据的传递,非常麻烦。
  • messenger 传递数据效率是比较低的,因为它会通过 Master 来做中转;万一 IPC 通道出现问题还可能将 Master 进程搞挂。

3.2 egg进程间通信方式二

参见Egg官方文档:多进程研发模式增强

Egg官方提供一种新的模式来降低这类客户端封装的复杂度。通过建立 AgentWorkersocket 直连跳过 Master 的中转Agent 作为对外的门面维持多个 Worker 进程的共享连接。

新的模式下,客户端的通信方式如下:

             +-------+
             | start |
             +---+---+
                 |
        +--------+---------+
      __| port competition |__
win /   +------------------+  \ lose
   /                           \
+---------------+     tcp conn     +-------------------+
| Leader(Agent) |<---------------->| Follower(Worker1) |
+---------------+                  +-------------------+
    |            \ tcp conn
    |             \
+--------+         +-------------------+
| Client |         | Follower(Worker2) |
+--------+         +-------------------+

4. 参考

  1. 多进程模型和进程间通讯