Node.js多进程cluster 原理

713 阅读2分钟

Node启动运行时默认一个进程,只能在一个 CPU 中进行运算,无法应用服务器的多核 CPU,因此我们需要寻求一些解决方案。使用多进程分发策略,即主进程接收所有请求,然后通过一定的负载均衡策略可以将处理逻辑分发到不同的 Node.js 子进程中。

有 2 个不同的实现:

  • 主进程监听一个端口,子进程不监听端口,通过主进程分发请求到子进程;
  • 主进程和子进程分别监听不同端口,通过主进程分发请求到子进程。

在 Node.js 中的 cluster 模式使用的是第一个实现。

cluster 模式

先来实现一个简单的 app.js

const http = require('http');
/**
 * 
 * 创建 http 服务,简单返回
 */
const server = http.createServer((req, res) => {
    res.write(`hello world, start with cluster ${process.pid}`);
    res.end();
});
/**
 * 
 * 启动服务
 */
server.listen(3000, () => {
    console.log('server start http://127.0.0.1:3000');
});
console.log(`Worker ${process.pid} started`);

使用cluster包装启动

const cluster = require('cluster');
const instances = 2; // 启动进程数量
if (cluster.isMaster) { // 判断是否为主进程
    for(let i = 0;i<instances;i++) { // 使用 cluster.fork 创建子进程
        cluster.fork();
    }
} else {
    require('./app.js');
}

node cluster.js启动后访问localhost:3000,发现打印的进程 ID 是比较有规律的两个随机数,两者就是我们 fork 出来的两个子进程ID

那Node.js 的 cluster 是如何做到多个进程监听一个端口的;又是如何进行负载均衡请求分发的呢?

首先看这一段引入:

const cluster = require('cluster');

从cluster源码中可以看到导出逻辑:

const childOrPrimary = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'primary';
module.exports = require(`internal/cluster/${childOrPrimary}`);

导出时通过'NODE_UNIQUE_ID'是否存在于process.env对象中分别导出internal/cluster下的child.js或者primary.js,对应的就是子进程和主进程文件,第一次默认是会走到primary,cluster.isMaster为true,会走到

cluster.fork()

源码如下:

cluster.fork = function(env) {
	  cluster.setupPrimary(); // 创建主进程
	  const id = ++ids;
	  const workerProcess = createWorkerProcess(id, env);
	  const worker = new Worker({
	    id: id,
	    process: workerProcess
	  }); // 创建worker子进程
	  worker.on('message', function(message, handle) {
	    cluster.emit('message', this, message, handle);
	  }); // worker子进程结束处理来自主进程的message并处理
}

cluster.setupPrimary源码如下,可以看到 cluster.fork,一开始就会调用 setupPrimary 方法,创建主进程,由于该方法是通过 cluster.fork 调用,因此会调用多次,但是该模块有个全局变量 initialized 用来区分是否为首次,如果是首次则创建,否则则跳过:

// 下面这三个参数会在node内部功能实现的时候用到,之后我们看net源码的时候会用到这些参数
cluster.isWorker = false; // 是否是工作进程
cluster.isMaster = true; // 是否是主进程
cluster.isPrimary = true; // 是否是主进程

cluster.setupPrimary = function(options) {
  // ...
  if (initialized === true)
    return process.nextTick(setupSettingsNT, settings);

  initialized = true; // 区分是否为首次
  schedulingPolicy = cluster.schedulingPolicy;  // Freeze policy.
  // ...
  // 处理来自子进程的消息
  process.nextTick(setupSettingsNT, settings);
	// ...
};

createWorkerProcess实际上是调用child_process的fork方法创建子进程

const { fork } = require('child_process');
function createWorkerProcess(id, env) {
  ...
  return fork(cluster.settings.exec, cluster.settings.args, {
    cwd: cluster.settings.cwd,
    env: workerEnv,
    serialization: cluster.settings.serialization,
    silent: cluster.settings.silent,
    windowsHide: cluster.settings.windowsHide,
    execArgv: execArgv,
    stdio: cluster.settings.stdio,
    gid: cluster.settings.gid,
    uid: cluster.settings.uid
  });
}

在创建后又会调用我们项目根目录下的 cluster.js 启动一个新实例,这时候由于 cluster.isMaster 是 false,因此会 require 到 internal/cluster/child 这个方法。

由于是 worker 进程,因此代码会 require ('./app.js') 模块,在该模块中会监听具体的端口

server.listen(3000, () => {
    console.log('server start http://127.0.0.1:3000');
});
console.log(`Worker ${process.pid} started`);

server.listen 会调用该模块中的 listenInCluster 方法:

function listenInCluster(server, address, port, addressType,
                         backlog, fd, exclusive, flags) {
  exclusive = !!exclusive;

  if (cluster === undefined) cluster = require('cluster');

  if (cluster.isPrimary || exclusive) { // 判断是否为主进程
    // Will create a new handle
    // _listen2 sets up the listened handle, it is still named like this
    // to avoid breaking code that wraps this method
    server._listen2(address, port, addressType, backlog, fd, flags);
    return;
  }

  const serverQuery = {
    address: address,
    port: port,
    addressType: addressType,
    fd: fd,
    flags,
  };

  // Get the primary's server handle, and listen on it
  cluster._getServer(server, serverQuery, listenOnPrimaryHandle);

  function listenOnPrimaryHandle(err, handle) {
    err = checkBindError(err, port, handle);

    if (err) {
      const ex = exceptionWithHostPort(err, 'bind', address, port);
      return server.emit('error', ex);
    }

    // Reuse primary's server handle
    server._handle = handle;
    // _listen2 sets up the listened handle, it is still named like this
    // to avoid breaking code that wraps this method
    server._listen2(address, port, addressType, backlog, fd, flags);
  }
}

上面代码中的第 7 行,判断为主进程,就是真实的监听端口启动服务,而如果非主进程则调用 cluster._getServer 方法,也就是 internal/cluster/child 中的 cluster._getServer 方法。

// `obj` is a net#Server or a dgram#Socket object.
cluster._getServer = function(obj, options, cb) {
  // ...
  const message = {
    act: 'queryServer',
    index,
    data: null,
    ...options
  };

  message.address = address;

  // Set custom data on handle (i.e. tls tickets key)
  if (obj._getServerData)
    message.data = obj._getServerData();
  send(message, (reply, handle) => {
    if (typeof obj._setServerData === 'function')
      obj._setServerData(reply.data);

    if (handle)
      shared(reply, handle, indexesKey, cb);  // Shared listen socket.
    else
      rr(reply, indexesKey, cb);              // Round-robin.
  });

  obj.once('listening', () => {
    cluster.worker.state = 'listening';
    const address = obj.address();
    message.act = 'listening';
    message.port = (address && address.port) || options.port;
    send(message);
  });
};

重点在26行到32行,通过 send 方法,如果监听到 listening 发送一个消息给到主进程,主进程也有一个同样的 listening 事件,监听到该事件后将子进程通过 EventEmitter 绑定在主进程上,这样就完成了主子进程之间的关联绑定,并且只监听了一个端口。而主子进程之间的通信方式,就是我们常听到的 IPC 通信方式

负载均衡

既然 Node.js cluster 模块使用的是主子进程方式,那么它是如何进行负载均衡处理的呢,这里就会涉及 Node.js cluster 模块中的两个模块。

round_robin_handle.js(非 Windows 平台应用模式),这是一个轮询处理模式,也就是轮询调度分发给空闲的子进程,处理完成后回到 worker 空闲池子中,这里要注意的就是如果绑定过就会复用该子进程,如果没有则会重新判断,这里可以通过上面的 app.js 代码来测试,用浏览器去访问,你会发现每次调用的子进程 ID 都会不变。

shared_handle.js( Windows 平台应用模式),通过将文件描述符、端口等信息传递给子进程,子进程通过信息创建相应的 SocketHandle / ServerHandle,然后进行相应的端口绑定和监听、处理请求。