Node.js 多进程之 cluster 模块

1,167 阅读4分钟

Node多进程架构的原理 中,我们已经讲解了多进程的原理,本文就其易用的应用模块 cluster 展开介绍。

单个 Node.js 实例运行在单个线程中。 为了充分利用多核系统,有时需要启用一组 Node.js 进程去处理负载任务。

在 Node v0.8 版本之前,实现多进程架构必须通过 child_process 来实现,要创建单机 Node 集群,由于有这么多细节需要处理,对普通工程师而言是一件相对较难的工作。

Node v0.8 引入了 cluster模块 ,用以解决多核 CPU 利用率的问题,同时也提供了较完善的 API ,用以处理进程的健壮性问题。

cluster模块 可以创建共享服务器端口的子进程。

// 推荐写法,主、子进程分离
// server.js
const cluster = require('cluster');
const numCPUs = require('os').cpus().length;

cluster.setupMaster({ 
  exec: "worker.js"  // 此参数为工作进程的文件路径
});

var cpus = require('os').cpus(); 

for (var i = 0; i < numCPUs; i++) { 
  // 创建多个工作进程之后, 程序就会相应启动n次工作进程
  // 这里指定工作进程为 worker.js 文件,所以其执行n次
  cluster.fork(); 
}

// worker.js
// 工作进程可以共享任何 TCP 连接。
// 在本例子中,共享的是 HTTP 服务器。
http.createServer((req, res) => {
  res.writeHead(200);
  res.end('你好世界\n');
}).listen(8000);

console.log(`工作进程 ${process.pid} 已启动`);

执行 node server.js 将会得到与前文创建子进程集群效果相同。就官方的文档而言,它更喜欢如下的形式作为示例:

// 官方示例,个人不推荐
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

// 执行时判断当前运行的进程为主进程还是工作进程,主进程就创建多个工作进程,工作进程就创建http server应用
if (cluster.isMaster) {
  console.log(`主进程 ${process.pid} 正在运行`);

  // 衍生(fork)工作进程。
  for (let i = 0; i < numCPUs; i++) {
    // 创建多个工作进程之后, 程序就会相应启动n次工作进程
    // 这里工作进程与主进程为同一文件,所以其执行n + 1次此文件
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`工作进程 ${worker.process.pid} 已退出`);
  });
} else {
  // 工作进程可以共享任何 TCP 连接。
  // 在本例子中,共享的是 HTTP 服务器。
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('你好世界\n');
  }).listen(8000);

  console.log(`工作进程 ${process.pid} 已启动`);
}

运行代码,则工作进程会共享 8000 端口:

$ node server.js
主进程 3596 正在运行
工作进程 4324 已启动
工作进程 4520 已启动
工作进程 6056 已启动
工作进程 5644 已启动

在进程中判断是主进程还是工作进程,主要取决于环境变量中是否有 NODE_UNIQUE_ID ,如下所示:

cluster.isWorker = ('NODE_UNIQUE_ID' in process.env); 
cluster.isMaster = (cluster.isWorker === false);

但是官方示例中,忽而判断 cluster.isMaster、忽而判断 cluster.isWorker,对于代码的可读性十分差。我建议用 cluster.setupMaster() 这个API,将主进程和工作进程从代码上完全剥离,如同 send() 方法看起来直接将服务器从主进程发送到子进程那样神奇,剥离代码之后,甚至都感觉不到主进程中有任何服务器相关代码。

通过 cluster.setupMaster() 创建子进程而不是使用 cluster.fork() ,程序结构不再凌乱,逻辑分明,代码的可读性和可维护性较好。

Cluster 工作原理

事实上,cluster 模块就是 child_processnet 模块的组合应用。

cluster 启动时,如同我们在 Node多进程架构的原理 中的代码一样,它会在内部启动 TCP 服务器,在 cluster.fork() 子进程时,将这个 TCP 服务器端的 socket 的文件描述符发送给 工作进程。

如果进程是通过 cluster.fork() 复制出来的,那么它的环境变量里就存在 NODE_UNIQUE_ID ,如果工作进程中存在 listen() 侦听网络端口的调用,它将拿到该文件描述符,通过 SO_REUSEADDR 端口重用,从而实现多个子进程共享端口。对于普通方法启动的进程,则不存在文件描述符传递共享等事情。

在 cluster 内部隐式创建 TCP 服务器的方式对使用者来说十分透明,但也正是这种方式使得它无法如直接使用 child_process 那样灵活。在 cluster 模块应用中,一个主进程只能管理一组工作进程,如下所示:

深入浅出Node.js 2019-10-11 09-29-21.png

对于自行通过 child_process 来操作时,则可以更灵活地控制工作进程,甚至控制多组工作进程。其原因在于自行通过 child_process 操作子进程时,可以隐式地创建多个 TCP 服务器,使得子进程可以共享多个的服务器端socket,如下所示:

深入浅出Node.js 2019-10-11 21-28-54.png

Cluster 事件

对于健壮性处理,cluster模块也暴露了相当多的事件。

  • fork: 复制一个工作进程后,触发该事件。
  • online: 复制好一个工作进程后,工作进程主动发送一条online消息给主进程,主进程收到消息后,触发该事件。
  • listening: 工作进程中调用listen()(共享了服务器端Socket)后,发送一条listening消息给主进程,主进程收到消息后,触发该事件。
  • disconnect: 主进程和工作进程之间IPC通道断开后,触发该事件。
  • exit: 有工作进程退出时,触发该事件。
  • setup: cluster.setupMaster()执行后,触发该事件。

这些事件大多跟 child_process 模块的事件相关,在进程间消息传递的基础上完成的封装。这些事件对于增强应用的健壮性已经足够了。

更多详细信息请参考:nodejs.cn/api/cluster…

参考

  1. 深入浅出NodeJs by 朴灵