Node.js是单进程单线程的应用,这种架构带来的缺点是不能很好地利用多核的能力,因为一个线程同时只能在一个核上执行。child_process模块一定程度地解决了这个问题,child_process模块使得Node.js应用可以在多个核上执行,而cluster模块在child_process模块的基础上使得多个进程可以监听的同一个端口,实现服务器的多进程架构。本章分析cluster模块的使用和原理。

15.1 cluster使用例子

我们首先看一下cluster的一个使用例子。

  1. const cluster = require('cluster');
  2. const http = require('http');
  3. const numCPUs = require('os').cpus().length;
  4. if (cluster.isMaster) {
  5. for (let i = 0; i < numCPUs; i++) {
  6. cluster.fork();
  7. }
  8. } else {
  9. http.createServer((req, res) => {
  10. res.writeHead(200);
  11. res.end('hello world\n');
  12. }).listen(8888);
  13. }

以上代码在第一次执行的时候,cluster.isMaster为true,说明是主进程,然后通过fork调用创建一个子进程,在子进程里同样执行以上代码,但是cluster.isMaster为false,从而执行else的逻辑,我们看到每个子进程都会监听8888这个端口但是又不会引起EADDRINUSE错误。下面我们来分析一下具体的实现。

15.2 主进程初始化

我们先看主进程时的逻辑。我们看一下require(‘cluster’)的时候,Node.js是怎么处理的。

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

我们看到Node.js会根据当前环境变量的值加载不同的模块,后面我们会看到NODE_UNIQUE_ID是主进程给子进程设置的,在主进程中,NODE_UNIQUE_ID是不存在的,所以主进程时,会加载master模块。

  1. cluster.isWorker = false;
  2. cluster.isMaster = true;
  3. // 调度策略
  4. cluster.SCHED_NONE = SCHED_NONE;
  5. cluster.SCHED_RR = SCHED_RR;
  6. // 调度策略的选择
  7. let schedulingPolicy = {
  8. 'none': SCHED_NONE,
  9. 'rr': SCHED_RR
  10. }[process.env.NODE_CLUSTER_SCHED_POLICY];
  11. if (schedulingPolicy === undefined) {
  12. schedulingPolicy = (process.platform === 'win32') ?
  13. SCHED_NONE : SCHED_RR;
  14. }
  15. cluster.schedulingPolicy = schedulingPolicy;
  16. // 创建子进程
  17. cluster.fork = function(env) {
  18. // 参数处理
  19. cluster.setupMaster();
  20. const id = ++ids;
  21. // 调用child_process模块的fork
  22. const workerProcess = createWorkerProcess(id, env);
  23. const worker = new Worker({
  24. id: id,
  25. process: workerProcess
  26. });
  27. // ...
  28. worker.process.on('internalMessage', internal(worker, onmessage));
  29. process.nextTick(emitForkNT, worker);
  30. cluster.workers[worker.id] = worker;
  31. return worker;
  32. };

cluster.fork是对child_process模块fork的封装,每次cluster.fork的时候,就会新建一个子进程,所以cluster下面会有多个子进程,Node.js提供的工作模式有轮询和共享两种,下面会具体介绍。Worker是对子进程的封装,通过process持有子进程的实例,并通过监听internalMessage和message事件完成主进程和子进程的通信,internalMessage这是Node.js定义的内部通信事件,处理函数是internal(worker, onmessage)。我们先看一下internal。

  1. const callbacks = new Map();
  2. let seq = 0;
  3. function internal(worker, cb) {
  4. return function onInternalMessage(message, handle) {
  5. if (message.cmd !== 'NODE_CLUSTER')
  6. return;
  7. let fn = cb;
  8. if (message.ack !== undefined) {
  9. const callback = callbacks.get(message.ack);
  10. if (callback !== undefined) {
  11. fn = callback;
  12. callbacks.delete(message.ack);
  13. }
  14. }
  15. fn.apply(worker, arguments);
  16. };
  17. }

internal函数对异步消息通信做了一层封装,因为进程间通信是异步的,当我们发送多个消息后,如果收到一个回复,我们无法辨别出该回复是针对哪一个请求的,Node.js通过seq的方式对每一个请求和响应做了一个编号,从而区分响应对应的请求。接着我们看一下message的实现。

  1. function onmessage(message, handle) {
  2. const worker = this;
  3. if (message.act === 'online')
  4. online(worker);
  5. else if (message.act === 'queryServer')
  6. queryServer(worker, message);
  7. else if (message.act === 'listening')
  8. listening(worker, message);
  9. else if (message.act === 'exitedAfterDisconnect')
  10. exitedAfterDisconnect(worker, message);
  11. else if (message.act === 'close')
  12. close(worker, message);
  13. }

onmessage根据收到消息的不同类型进行相应的处理。后面我们再具体分析。至此,主进程的逻辑就分析完了。

15.3 子进程初始化

我们来看一下子进程的逻辑。当执行子进程时,会加载child模块。

  1. const cluster = new EventEmitter();
  2. const handles = new Map();
  3. const indexes = new Map();
  4. const noop = () => {};
  5. module.exports = cluster;
  6. cluster.isWorker = true;
  7. cluster.isMaster = false;
  8. cluster.worker = null;
  9. cluster.Worker = Worker;
  10. cluster._setupWorker = function() {
  11. const worker = new Worker({
  12. id: +process.env.NODE_UNIQUE_ID | 0,
  13. process: process,
  14. state: 'online'
  15. });
  16. cluster.worker = worker;
  17. process.on('internalMessage', internal(worker, onmessage));
  18. // 通知主进程子进程启动成功
  19. send({ act: 'online' });
  20. function onmessage(message, handle) {
  21. if (message.act === 'newconn')
  22. onconnection(message, handle);
  23. else if (message.act === 'disconnect')
  24. _disconnect.call(worker, true);
  25. }
  26. };

_setupWorker函数在子进程初始化时被执行,和主进程类似,子进程的逻辑也不多,监听internalMessage事件,并且通知主线程自己启动成功。

15.4 http.createServer的处理

主进程和子进程执行完初始化代码后,子进程开始执行业务代码http.createServer,在HTTP模块章节我们已经分析过http.createServer的过程,这里就不具体分析,我们知道http.createServer最后会调用net模块的listen,然后调用listenIncluster。我们从该函数开始分析。

  1. function listenIncluster(server, address, port, addressType,
  2. backlog, fd, exclusive, flags) {
  3. const serverQuery = {
  4. address: address,
  5. port: port,
  6. addressType: addressType,
  7. fd: fd,
  8. flags,
  9. };
  10. cluster._getServer(server, serverQuery, listenOnMasterHandle);
  11. function listenOnMasterHandle(err, handle) {
  12. err = checkBindError(err, port, handle);
  13. if (err) {
  14. const ex = exceptionWithHostPort(err,
  15. 'bind',
  16. address,
  17. port);
  18. return server.emit('error', ex);
  19. }
  20. server._handle = handle;
  21. server._listen2(address,
  22. port,
  23. addressType,
  24. backlog,
  25. fd,
  26. flags);
  27. }
  28. }

listenIncluster函数会调用子进程cluster模块的_getServer。

  1. cluster._getServer = function(obj, options, cb) {
  2. let address = options.address;
  3. // 忽略index的处理逻辑
  4. const message = {
  5. act: 'queryServer',
  6. index,
  7. data: null,
  8. ...options
  9. };
  10. message.address = address;
  11. // 给主进程发送消息
  12. send(message, (reply, handle) => {
  13. // 根据不同模式做处理
  14. if (handle)
  15. shared(reply, handle, indexesKey, cb);
  16. else
  17. rr(reply, indexesKey, cb);
  18. });
  19. };

_getServer会给主进程发送一个queryServer的请求。我们看一下send函数。

  1. function send(message, cb) {
  2. return sendHelper(process, message, null, cb);
  3. }
  4. function sendHelper(proc, message, handle, cb) {
  5. if (!proc.connected)
  6. return false;
  7. message = { cmd: 'NODE_CLUSTER', ...message, seq };
  8. if (typeof cb === 'function')
  9. callbacks.set(seq, cb);
  10. seq += 1;
  11. return proc.send(message, handle);
  12. }

send调用了sendHelper,sendHelper是对异步请求做了一个封装,我们看一下主进程是如何处理queryServer请求的。

  1. function queryServer(worker, message) {
  2. const key = `${message.address}:${message.port}:${message.addressType}:` + `${message.fd}:${message.index}`;
  3. let handle = handles.get(key);
  4. if (handle === undefined) {
  5. let address = message.address;
  6. let constructor = RoundRobinHandle;
  7. // 根据策略选取不同的构造函数
  8. if (schedulingPolicy !== SCHED_RR ||
  9. message.addressType === 'udp4' ||
  10. message.addressType === 'udp6') {
  11. constructor = SharedHandle;
  12. }
  13. handle = new constructor(key,
  14. address,
  15. message.port,
  16. message.addressType,
  17. message.fd,
  18. message.flags);
  19. handles.set(key, handle);
  20. }
  21. handle.add(worker, (errno, reply, handle) => {
  22. const { data } = handles.get(key);
  23. send(worker, {
  24. errno,
  25. key,
  26. ack: message.seq,
  27. data,
  28. ...reply
  29. }, handle);
  30. });
  31. }

queryServer首先根据调度策略选择构造函数,然后执行对应的add方法并且传入一个回调。下面我们看看不同模式下的处理。

15.5 共享模式

下面我们首先看一下共享模式的处理,逻辑如图19-1所示。
15-Cluster - 图1
图19-1

  1. function SharedHandle(key, address, port, addressType, fd, flags) {
  2. this.key = key;
  3. this.workers = [];
  4. this.handle = null;
  5. this.errno = 0;
  6. let rval;
  7. if (addressType === 'udp4' || addressType === 'udp6')
  8. rval = dgram._createSocketHandle(address,
  9. port,
  10. addressType,
  11. fd,
  12. flags);
  13. else
  14. rval = net._createServerHandle(address,
  15. port,
  16. addressType,
  17. fd,
  18. flags);
  19. if (typeof rval === 'number')
  20. this.errno = rval;
  21. else
  22. this.handle = rval;
  23. }

SharedHandle是共享模式,即主进程创建好handle,交给子进程处理。

  1. SharedHandle.prototype.add = function(worker, send) {
  2. this.workers.push(worker);
  3. send(this.errno, null, this.handle);
  4. };

SharedHandle的add把SharedHandle中创建的handle返回给子进程,接着我们看看子进程拿到handle后的处理

  1. function shared(message, handle, indexesKey, cb) {
  2. const key = message.key;
  3. const close = handle.close;
  4. handle.close = function() {
  5. send({ act: 'close', key });
  6. handles.delete(key);
  7. indexes.delete(indexesKey);
  8. return close.apply(handle, arguments);
  9. };
  10. handles.set(key, handle);
  11. // 执行net模块的回调
  12. cb(message.errno, handle);
  13. }

Shared函数把接收到的handle再回传到调用方。即net模块。net模块会执行listen开始监听地址,但是有连接到来时,系统只会有一个进程拿到该连接。所以所有子进程存在竞争关系导致负载不均衡,这取决于操作系统的实现。
共享模式实现的核心逻辑主进程在_createServerHandle创建handle时执行bind绑定了地址(但没有listen),然后通过文件描述符传递的方式传给子进程,子进程执行listen的时候就不会报端口已经被监听的错误了。因为端口被监听的错误是执行bind的时候返回的。

15.6 轮询模式

接着我们看一下RoundRobinHandle的处理,逻辑如图19-2所示。
15-Cluster - 图2
图19-2

  1. function RoundRobinHandle(key, address, port, addressType, fd, flags) {
  2. this.key = key;
  3. this.all = new Map();
  4. this.free = [];
  5. this.handles = [];
  6. this.handle = null;
  7. this.server = net.createServer(assert.fail);
  8. if (fd >= 0)
  9. this.server.listen({ fd });
  10. else if (port >= 0) {
  11. this.server.listen({
  12. port,
  13. host: address,
  14. ipv6Only: Boolean(flags & constants.UV_TCP_IPV6ONLY),
  15. });
  16. } else
  17. this.server.listen(address); // UNIX socket path.
  18. // 监听成功后,注册onconnection回调,有连接到来时执行
  19. this.server.once('listening', () => {
  20. this.handle = this.server._handle;
  21. this.handle.onconnection = (err, handle) => this.distribute(err, handle);
  22. this.server._handle = null;
  23. this.server = null;
  24. });
  25. }

RoundRobinHandle的工作模式是主进程负责监听,收到连接后分发给子进程。我们看一下RoundRobinHandle的add

  1. RoundRobinHandle.prototype.add = function(worker, send) {
  2. this.all.set(worker.id, worker);
  3. const done = () => {
  4. if (this.handle.getsockname) {
  5. const out = {};
  6. this.handle.getsockname(out);
  7. send(null, { sockname: out }, null);
  8. } else {
  9. send(null, null, null); // UNIX socket.
  10. }
  11. // In case there are connections pending.
  12. this.handoff(worker);
  13. };
  14. // 说明listen成功了
  15. if (this.server === null)
  16. return done();
  17. // 否则等待listen成功后执行回调
  18. this.server.once('listening', done);
  19. this.server.once('error', (err) => {
  20. send(err.errno, null);
  21. });
  22. };

RoundRobinHandle会在listen成功后执行回调。我们回顾一下执行add函数时的回调。

  1. handle.add(worker, (errno, reply, handle) => {
  2. const { data } = handles.get(key);
  3. send(worker, {
  4. errno,
  5. key,
  6. ack: message.seq,
  7. data,
  8. ...reply
  9. }, handle);
  10. });

回调函数会把handle等信息返回给子进程。但是在RoundRobinHandle和SharedHandle中返回的handle是不一样的。分别是null和net.createServer实例。接着我们回到子进程的上下文。看子进程是如何处理响应的。刚才我们讲过,不同的调度策略,返回的handle是不一样的,我们看轮询模式下的处理。

  1. function rr(message, indexesKey, cb) {
  2. let key = message.key;
  3. function listen(backlog) {
  4. return 0;
  5. }
  6. function close() {
  7. // ...
  8. }
  9. const handle = { close, listen, ref: noop, unref: noop };
  10. if (message.sockname) {
  11. handle.getsockname = getsockname; // TCP handles only.
  12. }
  13. handles.set(key, handle);
  14. // 执行net模块的回调
  15. cb(0, handle);
  16. }

round-robin模式下,构造一个假的handle返回给调用方,因为调用方会调用这些函数。最后回到net模块。net模块首先保存handle,然后调用listen函数。当有请求到来时,round-bobin模块会执行distribute分发请求给子进程。

  1. RoundRobinHandle.prototype.distribute = function(err, handle) {
  2. // 首先保存handle到队列
  3. this.handles.push(handle);
  4. // 从空闲队列获取一个子进程
  5. const worker = this.free.shift();
  6. // 分发
  7. if (worker)
  8. this.handoff(worker);
  9. };
  10. RoundRobinHandle.prototype.handoff = function(worker) {
  11. // 拿到一个handle
  12. const handle = this.handles.shift();
  13. // 没有handle,则子进程重新入队
  14. if (handle === undefined) {
  15. this.free.push(worker); // Add to ready queue again.
  16. return;
  17. }
  18. // 通知子进程有新连接
  19. const message = { act: 'newconn', key: this.key };
  20. sendHelper(worker.process, message, handle, (reply) => {
  21. // 接收成功
  22. if (reply.accepted)
  23. handle.close();
  24. else
  25. // 结束失败,则重新分发
  26. this.distribute(0, handle); // Worker is shutting down. Send to another.
  27. this.handoff(worker);
  28. });
  29. };

接着我们看一下子进程是怎么处理该请求的。

  1. function onmessage(message, handle) {
  2. if (message.act === 'newconn')
  3. onconnection(message, handle);
  4. }
  5. function onconnection(message, handle) {
  6. const key = message.key;
  7. const server = handles.get(key);
  8. const accepted = server !== undefined;
  9. // 回复接收成功
  10. send({ ack: message.seq, accepted });
  11. if (accepted)
  12. // 在net模块设置
  13. server.onconnection(0, handle);
  14. }

我们看到子进程会执行server.onconnection,这个和我们分析net模块时触发onconnection事件是一样的。

15.7实现自己的cluster模块

Node.js的cluster在请求分发时是按照轮询的,无法根据进程当前情况做相应的处理。了解了cluster模块的原理后,我们自己来实现一个cluster模块。

15.7.1 轮询模式

整体架构如图15-3所示。
15-Cluster - 图3
图15-3
Parent.js

  1. const childProcess = require('child_process');
  2. const net = require('net');
  3. const workers = [];
  4. const workerNum = 10;
  5. let index = 0;
  6. for (let i = 0; i < workerNum; i++) {
  7. workers.push(childProcess.fork('child.js', {env: {index: i}}));
  8. }
  9. const server = net.createServer((client) => {
  10. workers[index].send(null, client);
  11. console.log('dispatch to', index);
  12. index = (index + 1) % workerNum;
  13. });
  14. server.listen(11111);

child.js

  1. process.on('message', (message, client) => {
  2. console.log('receive connection from master');
  3. });

主进程负责监听请求,主进程收到请求后,按照一定的算法把请求通过文件描述符的方式传给worker进程,worker进程就可以处理连接了。在分发算法这里,我们可以根据自己的需求进行自定义,比如根据当前进程的负载,正在处理的连接数。

15.7.2 共享模式

整体架构如图15-4所示。
15-Cluster - 图4
图15-4
Parent.js

  1. const childProcess = require('child_process');
  2. const net = require('net');
  3. const workers = [];
  4. const workerNum = 10 ;
  5. const handle = net._createServerHandle('127.0.0.1', 11111, 4);
  6. for (let i = 0; i < workerNum; i++) {
  7. const worker = childProcess.fork('child.js', {env: {index: i}});
  8. workers.push(worker);
  9. worker.send(null ,handle);
  10. /*
  11. 防止文件描述符泄漏,但是重新fork子进程的时候就无法
  12. 再传递了文件描述符了
  13. */
  14. handle.close();
  15. }

Child.js

  1. const net = require('net');
  2. process.on('message', (message, handle) => {
  3. net.createServer(() => {
  4. console.log(process.env.index, 'receive connection');
  5. }).listen({handle});
  6. });

我们看到主进程负责绑定端口,然后把handle传给worker进程,worker进程各自执行listen监听socket。当有连接到来的时候,操作系统会选择某一个worker进程处理该连接。我们看一下共享模式下操作系统中的架构,如图15-5所示。
15-Cluster - 图5
图15-5
实现共享模式的重点在于理解EADDRINUSE错误是怎么来的。当主进程执行bind的时候,结构如图15-6所示。
15-Cluster - 图6
图15-6
如果其它进程也执行bind并且端口也一样,则操作系统会告诉我们端口已经被监听了(EADDRINUSE)。但是如果我们在子进程里不执行bind的话,就可以绕过这个限制。那么重点在于,如何在子进程中不执行bind,但是又可以绑定到同样的端口呢?有两种方式。
1 fork
我们知道fork的时候,子进程会继承主进程的文件描述符,如图15-7所示。
15-Cluster - 图7
图15-7
这时候,主进程可以执行bind和listen,然后fork子进程,最后close掉自己的fd,让所有的连接都由子进程处理就行。但是在Node.js中,我们无法实现,所以这种方式不能满足需求。
2 文件描述符传递
Node.js的子进程是通过fork+exec模式创建的,并且Node.js文件描述符设置了close_on_exec标记,这就意味着,在Node.js中,创建子进程后,文件描述符的结构体如图15-8所示(有标准输入、标准输出、标准错误三个fd)。
15-Cluster - 图8
图15-8
这时候我们可以通过文件描述符传递的方式。把方式1中拿不到的fd传给子进程。因为在Node.js中,虽然我们拿不到fd,但是我们可以拿得到fd对应的handle,我们通过IPC传输handle的时候,Node.js会为我们处理fd的问题。最后通过操作系统对传递文件描述符的处理。结构如图15-9所示。
15-Cluster - 图9
图15-9
通过这种方式,我们就绕过了bind同一个端口的问题。通过以上的例子,我们知道绕过bind的问题重点在于让主进程和子进程共享socket而不是单独执行bind。对于传递文件描述符,Node.js中支持很多种方式。上面的方式是子进程各自执行listen。还有另一种模式如下
parent.js

  1. const childProcess = require('child_process');
  2. const net = require('net');
  3. const workers = [];
  4. const workerNum = 10;
  5. const server = net.createServer(() => {
  6. console.log('master receive connection');
  7. })
  8. server.listen(11111);
  9. for (let i = 0; i < workerNum; i++) {
  10. const worker = childProcess.fork('child.js', {env: {index: i}});
  11. workers.push(worker);
  12. worker.send(null, server);
  13. }

child.js

  1. const net = require('net');
  2. process.on('message', (message, server) => {
  3. server.on('connection', () => {
  4. console.log(process.env.index, 'receive connection');
  5. })
  6. });

上面的方式中,主进程完成了bind和listen。然后把server实例传给子进程,子进程就可以监听连接的到来了。这时候主进程和子进程都可以处理连接。
最后写一个客户端测试。
客户端

  1. const net = require('net');
  2. for (let i = 0; i < 50; i++) {
  3. net.connect({port: 11111});
  4. }

执行client我们就可以看到多进程处理连接的情况。