简单聊聊多进程架构和Cluster

1,472 阅读6分钟

在本文中主要会介绍多进程架构以及其问题,cluster 在多进程架构上解决了什么问题,以及其原理。

多进程架构

其实多进程的出现能够解决单进程、单线程无法充分利用多核 CPU 的问题。上述的父子进程事件监听中,我们能够通过 fork 创建 worker 子进程,并且通过 on/send 事件来进行事件监听。

但是 worker 进程会因为某些异常情况退出,因此在 master 进程中需要监听子进程的存活状态,一旦子进程退出之后,master 进程需要重启新的子进程。子进程退出时,会在父进程触发 exit 事件,父进程只需要监听该事件。

主进程 master

  • 创建 server 并监听3000端口
  • 根据系统的 cpus 开启多个进程
  • 通过子进程对象的 send 发送信息到子进程进行通信
  • 在主进程中监听子进程的变化,如果是自杀信号(suicide)重新启动一个工作进程
  • 主进程在监听到退出消息的时候,先退出子进程再退出主进程
const fork = require('child_process').fork;
const cpus = require('os').cpus();

const server = require('net').createServer();
server.listen(3000);
process.title = 'FBB-Master';

const workers = {};
const createWorker = () => {
    const worker = fork('worker.js');
    worker.on('message', function (message) {
        if (message.act === 'suicide') {
            createWorker();
        }
    });
    worker.on('exit', function (code, signal) {
        console.log(`worker process ${worker.pid} exited, code: ${code}, signal: ${signal}`);
        delete workers[worker.pid];
    });
    worker.send('server', server);
    workers[worker.pid] = worker;
    console.log(`worker process created, pid: ${worker.pid}, ppid: ${process.pid} `);
};

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

// SIGINT 程序终止(interrupt)信号 表示用户输入INTR字符(通常是Ctrl-C)时发出,用于通知前台进程组终止进程
process.once("SIGINT", close.bind(this, "SIGINT"))
// SIGINT类似, 但由QUIT字符(通常是Ctrl-\)来控制
process.once("SIGQUIT", close.bind(this, "SIGQUIT"))
// 程序结束(terminate)信号,通常用来要求程序自己正常退出
process.once("SIGTERM", close.bind(this, "SIGTERM"))
process.once("exit", close.bind(this))

function close(code) {
    console.log('进程退出!', code);
    if (code !== 0) {
        for (let pid in workers) {
            console.log('master process exited, kill worker pid:', pid);
            workers[pid].kill('SIGINT');
        }
    }
    process.exit(0);
}

工作进程 worker

  • 创建一个 server 对象
  • 通过 message 事件接受主进程 send 方法发送的消息
  • 监听 uncaughtException 事件,捕获未处理的异常,发送自杀信息由主进程重建进程,子进程在连接关闭之后再退出
const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200, {
        'Content-Type': 'text/plain',
    });
    res.end('I am worker, pid:' + process.pid + ', ppid:' + process.ppid);
    throw new Error('worker process exception!');
});

let netServer;
process.title = 'FBB-Worker';
process.on('message', function (message, sendHandle) {
    if (message === 'server') {
        netServer = sendHandle;
        netServer.on('connection', function (socket) {
            console.log(`got a connection on worker, pid = ${process.pid}`);
            server.emit('connection', socket);
        });
    }
});

process.on('uncaughtException', function (err) {
    console.log('catch error, send suicide massage');
    process.send({ act: 'suicide' });
    netServer.close(function () {
        process.exit(1);
    });
});

Nodejs 的多进程模型就是这样实现的,思考该模型是否有问题🤔

  • 多个进程之间会竞争一个连接,产生惊群现象,效率比较低
  • 无法控制一个新的连接是由哪个进程来处理的,导致各个 worker 之间非常不均衡

多线程/多进程等待同一个 socket 事件,当这个事件发生时,所有的线程/进程被同时唤醒,就是惊群。可以预见,这样的效率低下,许多进程被重新唤醒,同时去响应这一个事件,但只有一个进程能处理事件成功,其他的进程在处理该事件失败后重新休眠。这种性能浪费现象就是惊群

Cluster

cluster 模块可以被用来在多核 CPU 环境负载均衡,基于子进程的 fork 方法并且主要会根据 CPU 核数衍生很多次主应用进程。然后主进程接管并且通过主进程和子进程的交流实现负载均衡。

Cluster工作流程(Master-Worker)

cluster 中创建一个主进程(Master),以及若干个子进程(worker)。由于主进程监听客户端连接请求,并根据特定的策略转发到对应的 worker。

  • 优点: 通常只占用一个端口,通信相对简单,转发策略更加灵活
  • 缺点: 需要主进程的稳定性较高

image.png

主进程的工作很简单,只是使用一个调度轮询算法去选择一个工作进程,通过所有的可用的进程让负载均匀地分布。

集群创建

Cluster 是常见的 Nodejs 利用多核的方法,是基于上面讲的child_process.fork实现的,所以 cluster 产生的进程是通过 IPC 来通信的,是使用cluster.isMaster这个标识来区分子进程和父进程

我们开启一个进程,监听8080端口,使用apache基准测试工具来模拟请求

ab -c 10 -n 5000 -r http://localhost:8080,发起5000个请求

const http = require('http');
const pid = process.pid;
http.createServer((req, res) => {
    for (let i = 0; i < 1e7; i++) { } // simulate CPU work
    res.end(`handled by process.${pid}`);
}).listen(8080, () => {
    console.log(`started process`, pid);
});

当我们执行完5000条数据之后,我们能够得到如下的结果:

image.png

借助上述的示例,把它作为一个 worker,使用 cluster 模块来扩展当前示例,在主进程中根据电脑 cpu 的数量来创建对应的子进程

const http = require("http")
const cluster = require("cluster")
const cpus = require("os").cpus().length

if (cluster.isMaster) {
    for (let i = 0; i < cpus; i++) {
        cluster.fork();     // 创建子进程
    }
    cluster.on("exit", (worker) => {
        console.log(`${worker.process.pid} died`)
    })
} else {
    require("./worker.js")
}
console.log("hello all")

依旧执行上述命令,得到如下的结果。当我们多次请求时,请求会被不同的子进程处理。子进程并不是完全按着顺序轮流执行的,cluster 模块采用的是 round-robin 方式,将负载分布到不同的子进程之间

image.png

提出问题

  1. 如果进了 if(cluster.isMaster),如何进入 else 中的逻辑,并新建好几个子进程,进行通信?
  2. 为什么子进程中创建多个 server 的时候,监听了同一个端口,尚未报错?
  3. 主进程和子进程各自的作用是什么,分别负责了什么事情?

运行原理

执行流程和进程通信

master 进程通过 cluster.fork 创建了 worker 进程。cluster.fork 内部使用 child_process.fork 来创建进程的,所以 master 和 worker 之间也会有 IPC 通道进行通信

image.png

端口共享

在该示例中我们创建了多个 worker 并且监听的是同一个端口。当我们多个进程监听一个端口的时会报错,但是我们的代码中却没有问题?

上述代码中使用 http 模块创建了 http 服务,http 模块最终会调用 net.js 实现网络服务。net 中会根据当前是主进程还是子进程对 listen 方法做特殊处理

image.png

向 master 进程发送 queryServer 消息,master 在收到这个消息之后,向 master 注册一个内部的 TCP 服务器。

master 在接收到 queryServer 消息后进行服务启动:

  • 如果地址没有被监听过,通过 RoundRobinHandle 监听开启服务
  • 如果地址被监听过,直接绑定当已经监听的服务上,去消费请求

实现端口共享的原理:

  • 端口只会在 master 进程中的内部 TCP 服务器监听一次
  • net.js 模块中会判断当前的进程是 master 还是 worker 进程
  • 如果是 worker 进程并不会真正的去调用 listen 方法,所以不会报端口错误

主子进程分别负责什么?

master 进程主要负责请求的收集和分发,当一个请求进来之后会被 master 进程监听到,matser 采用 RoundRobin 的调度方式去分配请求。每一次从空闲子进程队列中拿出第一个,将请求转发到该子进程做处理,处理完成之后再将子进程放回空闲队列

image.png

源码分析

主进程初始化

image.png

当我们使用 require("cluster") 的时候,会去执行

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

当环境变量中没有 NODE_UNIQUE_ID 加载 primary.js

cluster.fork 是 child_process.fork 的封装,调用时会将 NODE_UNIQUE_ID 写到环境变量中

子进程初始化

image.png

当我们的创建子进程的时候,发现变量中有 NODE_UNIQUE_ID 会执行 initializeClusterIPC,间接执行 cluster._setupWorker,做一些事件监听并通知主进程启动成功

http.createServer

image.png

我们在子进程会通过 http.createServer().listen,会走到 Server.prototype.listen 方法中,根据判断之后会开始执行 listenInCluster 方法

// listenInCluster
function listenInCluster() {
  if (cluster === undefined) cluster = require('cluster');

  if (cluster.isPrimary) {
    server._listen2(address, port, addressType, backlog, fd, flags);
    return;
  }

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

这里的会对当前是在主进程还是子进程做出判断走不同的逻辑,目前还处于子进程中,调用 cluster._getServer 函数

在 cluster._getServer 中会构建一个消息体,再发送给主进程,开始和主进程进行通信,主进程

cluster._getServer = function(obj, options, cb) {
	const message = {
    act: 'queryServer',
    index,
    data: null,
    ...options
  };
	send(message, (reply, handle) => {
    if (handle) {
      // Shared listen socket
      shared(reply, { handle, indexesKey, index }, cb);
    } else {
      // Round-robin.
      rr(reply, { indexesKey, index }, cb);
    }
  });
}

当主进程接收到 queryServer 消息之后,会去执行 queryServer 函数。划重点: 当执行 queryServer 函数的时候会根据不同的调度策略选择不同的调用模式,默认选择 RoundRobinHandle 方法

function queryServer(worker, message) {
  const key = `${message.address}:${message.port}:${message.addressType}:` +`${message.fd}:${message.index}`;
  let handle = handles.get(key);
	
  // 主进程会创建 server
  if (handle === undefined) {
    if (schedulingPolicy !== SCHED_RR ||
      message.addressType === 'udp4' ||
      message.addressType === 'udp6') {
      handle = new SharedHandle(key, address, message);
    } else {
      // 走Round-Robin算法,负责处理请求时,主进程server选取哪个子进程server进行处理
      handle = new RoundRobinHandle(key, address, message);
    }
    handles.set(key, handle);
  }
  handle.add(worker, (errno, reply, handle) => {}
}

看看 RoundRobinHandle 调度做了什么事情?发现其中创建了一个新的 server 并且做了listen 监听,那么就会进入到 Server.prototype.listen 进入到 listenInCluster 方法中,由于目前是在主进程中执行,那么会开始执行 server._listen2 方法

function RoundRobinHandle(key, address, { port, fd, flags }) {
  this.key = key;
  this.all = new SafeMap();
  this.free = new SafeMap();
  this.handles = [];
  this.handle = null;
  // 在这里创建了主进程中的server,并且监听在子进程中定义的端口
  this.server = net.createServer(assert.fail);
  this.server.listen(address);
}

到这里的时候,我们已经分析完了子进程创建 Server 监听端口的时候,并在在主进程中创建了一个 Server 监听相同的端口

接下来将要分析的是,子进程是如何做到的不去监听端口的

当我们主进程的 Server 创建成功之后,会执行 handle.add 方法,调用 handoff 当前的子进程放入到一个 this.free 中

handle.add(worker, (errno, reply, handle) => {
    const { data } = handles.get(key);
    if (errno) handles.delete(key);  // Gives other workers a chance to retry.
    send(worker, {
      errno,
      key,
      ack: message.seq,
      data,
      ...reply
    }, handle);
  });

RoundRobinHandle.prototype.add = function(worker, send) {
  const done = () => {
    // 将当前的子进程推入this.free中(现在只是推入,等客户端请求时,会取出子进程使用)
    this.handoff(worker);
  };
  this.server.once('listening', done);
};

执行完毕之后,会开始执行发送 queryServer 消息时的回调函数

send(message, (reply, handle) => {
    if (handle) {
      // Shared listen socket
      shared(reply, { handle, indexesKey, index }, cb);
    } else {
      // Round-robin.
      rr(reply, { indexesKey, index }, cb);
    }
});

在 send 中的 cb 就是调用 clsuter._getServer 传入的 listenOnPrimaryHandle 方法,最终也会调用到 server._listen2(setupListenHandle)

其实 server._listen2(setupListenHandle) 主进程做监听的时候也调用了这个方法,我们来看看其真实面目吧

为主进程创建TCP句柄,实现子进程就不会再进行端口监听

function setupListenHandle(address, port, addressType, backlog, fd, flags) {
  // 当子进程在调用进这个方法的时候,已经有_handle了,所以不会再进行handle的创建
  if (this._handle) {
    debug('setupListenHandle: have a handle already');
  } else {
    // 主进程会调用
    let rval = null;
    // Try to bind to the unspecified IPv6 address, see if IPv6 is available
    if (!address && typeof fd !== 'number') {
      // 实际上是创建了主进程的handle句柄,创建好了tcp连接通路
      rval = createServerHandle(DEFAULT_IPV6_ADDR, port, 6, fd, flags);
    }
    this._handle = rval;
  }
}

分发客户端服务

image.png

当我们执行 curl http:127.0.0.1:3000 的时候,主进程会监听到有连接进程,能够触发主进程的 listening 事件

this.server.once('listening', () => {
  this.handle.onconnection = (err, handle) => this.distribute(err, handle);
});

触发 RoundRobinHandle 的 distribute 方法,取出空闲队列中的第一个进程,为它分配请求

RoundRobinHandle.prototype.distribute = function(err, handle) {
  // 把请求放入请求队列中
  append(this.handles, handle);
  // 空闲队列中取出第一个进程
  const [ workerEntry ] = this.free;

  if (ArrayIsArray(workerEntry)) {
    const { 0: workerId, 1: worker } = workerEntry;
    this.free.delete(workerId);
    this.handoff(worker);  
  }
};

执行 handoff 开始为所选进程分配请求,构建消息体,向子进程发送对应的消息和请求

RoundRobinHandle.prototype.handoff = function(worker) {
  // 取出队列中的第一个请求
  const handle = peek(this.handles);
  // 如果为空,说明没有请求,再次放回进程空闲队列中
  if (handle === null) {
    this.free.set(worker.id, worker);
    return;
  }

  remove(handle);
  // 构建消息体
  const message = { act: 'newconn', key: this.key };
  // 向子进程发送消息
  sendHelper(worker.process, message, handle, (reply) => {
    if (reply.accepted) handle.close();
    else this.distribute(0, handle);  // Worker is shutting down. Send to another.
    this.handoff(worker);
  });
};

当子进程接收到消息之后,会触发 onconnection 方法,来触发 net 模块 onconnection 方法,将对应的 socket 传递给用户层

function onmessage(message, handle) {
  if (message.act === 'newconn') onconnection(message, handle);
}

![Untitled 5.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0d3c86fdd05b474299aaf1831ed21785~tplv-k3u1fbpfcp-watermark.image?)
// Round-robin connection.
function onconnection(message, handle) {
  const key = message.key;
  const server = handles.get(key);
  const accepted = server !== undefined;

  send({ ack: message.seq, accepted });

  if (accepted) server.onconnection(0, handle);
  else handle.close();
}

// server.onconnection
function onconnection(err, clientHandle) {
  const handle = this;
  const self = handle[owner_symbol];
  const socket = new Socket({
    handle: clientHandle,
    allowHalfOpen: self.allowHalfOpen,
    pauseOnCreate: self.pauseOnConnect,
    readable: true,
    writable: true
  });
  self.emit('connection', socket);
}

参考连接