Node.js cluster模块解读

预备知识

学习cluster以前,须要了解process相关的知识,若是不了解的话建议先阅读process模块child_process模块node

cluster借助child_process模块的fork()方法来建立子进程,经过fork方式建立的子进程与父进程之间创建了IPC通道,支持双向通讯。nginx

cluster模块最先出如今node.js v0.8版本中windows

为何会存在cluster模块?

Node.js是单线程的,那么若是但愿利用服务器的多核的资源的话,就应该多建立几个进程,由多个进程共同提供服务。若是直接采用下列方式启动多个服务的话,会提示端口占用。bash

const http = require('http');
http.createServer((req, res) => {
  res.writeHead(200);
  res.end('hello world\n');
}).listen(8000);

// 启动第一个服务 node index.js &
// 启动第二个服务 node index.js &

      throw er; // Unhandled 'error' event
      ^

Error: listen EADDRINUSE :::8000
    at Server.setupListenHandle [as _listen2] (net.js:1330:14)
    at listenInCluster (net.js:1378:12)
    at Server.listen (net.js:1465:7)
    at Object.<anonymous> (/Users/xiji/workspace/learn/node-basic/cluster/simple.js:5:4)
    at Module._compile (internal/modules/cjs/loader.js:702:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:713:10)
    at Module.load (internal/modules/cjs/loader.js:612:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:551:12)
    at Function.Module._load (internal/modules/cjs/loader.js:543:3)
    at Function.Module.runMain (internal/modules/cjs/loader.js:744:10)
复制代码

若是改用cluster的话就没有问题服务器

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  console.log(`Master ${process.pid} is running`);

  // Fork workers.
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`);
  });
} else {
  // Workers can share any TCP connection
  // In this case it is an HTTP server
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('hello world\n');
  }).listen(8000);

  console.log(`Worker ${process.pid} started`);
}

// node index.js 执行完启动了一个主进程和8个子进程(子进程数与cpu核数相一致)
Master 11851 is running
Worker 11852 started
Worker 11854 started
Worker 11853 started
Worker 11855 started
Worker 11857 started
Worker 11858 started
Worker 11856 started
Worker 11859 started
复制代码

cluster是如何实现多进程共享端口的?

cluster建立的进程分两种,父进程和子进程,父进程只有一个,子进程有多个(通常根据cpu核数建立)app

  • 父进程负责监听端口接受请求,而后分发请求。
  • 子进程负责请求的处理。

有三个问题须要回答:负载均衡

  • 子进程为什么调用listen不会进行端口绑定
  • 父进程什么时候建立的TCP Server
  • 父进程是如何完成分发的

子进程为什么调用listen不会绑定端口?

net.js源码中的listen方法经过listenInCluster方法来区分是父进程仍是子进程,不一样进程的差别在listenInCluster方法中体现socket

function listenInCluster(server, address, port, addressType, backlog, fd, excluseive) {
  
  if (cluster.isMaster || exclusive) {
    server._listen2(address, port, addressType, backlog, fd);
    return;
  }

  const serverQuery = { address: address ......};

  cluster._getServer(server, serverQuery, listenOnMasterHandle);

  function listenOnMasterHandle(err, handle) {
    server._handle = handle;
    server._listen2(address, port, addressType, backlog, fd);
  }
}
复制代码

上面是精简过的代码,当子进程调用listen方法时,会先执行_getServer,而后经过callback的形式指定server._handle的值,以后再调用_listen2方法。函数

cluster._getServer = function(obj, options, cb) {
  ...
  const message = util._extend({
    act: 'queryServer',
    index: indexes[indexesKey],
    data: null
  }, options);

  message.address = address;

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

_getServer方法会向主进程发送queryServer的message,父进程执行完会调用回调函数,根据是否返回handle来区分是调用shared方法仍是rr方法,这里实际上是会调用rr方法。而rr方法的主要做用就是伪造了TCPWrapper来调用net的listenOnMasterHandle回调函数oop

function rr(message, indexesKey, cb) {

  var key = message.key;

  function listen(backlog) {
    return 0;
  }

  function close() {
    if (key === undefined)
      return;

    send({ act: 'close', key });
    delete handles[key];
    delete indexes[indexesKey];
    key = undefined;
  }

  function getsockname(out) {
    if (key)
      util._extend(out, message.sockname);

    return 0;
  }

  const handle = { close, listen, ref: noop, unref: noop };
  handles[key] = handle;
  cb(0, handle);
}
复制代码

因为子进程的server拿到的是围绕的TCPWrapper,当调用listen方法时并不会执行任何操做,因此在子进程中调用listen方法并不会绑定端口,于是也并不会报错。

父进程什么时候建立的TCP Server

在子进程发送给父进程的queryServer message时,父进程会检测是否建立了TCP Server,若是没有的话就会建立TCP Server并绑定端口,而后再把子进程记录下来,方便后续的用户请求worker分发。

父进程是如何完成分发的

父进程因为绑定了端口号,因此能够捕获链接请求,父进程的onconnection方法会被触发,onconnection方法触发时会传递TCP对象参数,因为以前父进程记录了全部的worker,因此父进程能够选择要处理请求的worker,而后经过向worker发送act为newconn的消息,并传递TCP对象,子进程监听到消息后,对传递过来的TCP对象进行封装,封装成socket,而后触发connection事件。这样就实现了子进程虽然不监听端口,可是依然能够处理用户请求的目的。

cluster如何实现负载均衡

负载均衡直接依赖cluster的请求调度策略,在v6.0版本以前,cluster的调用策略采用的是cluster.SCHED_NONE(依赖于操做系统),SCHED_NODE理论上来讲性能最好(Ferando Micalli写过一篇Node.js 6.0版本的cluster和iptables以及nginx性能对比的文章,点此访问)可是从实际角度发现,在请求调度方面会出现不太均匀的状况(可能出现8个子进程中的其中2到3个处理了70%的链接请求)。所以在6.0版本中Node.js增长了cluster.SCHED_RR(round-robin),目前已成为默认的调度策略(除了windows环境)

能够经过设置NODE_CLUSTER_SCHED_POLICY环境变量来修改调度策略

NODE_CLUSTER_SCHED_POLICY='rr'
NODE_CLUSTER_SCHED_POLICY='none'
复制代码

或者设置cluster的schedulingPolicy属性

cluster.schedulingPolicy = cluster.SCHED_NONE;
cluster.schedulingPolicy = cluster.SCHED_RR;
复制代码

Node.js实现round-robin

Node.js内部维护了两个队列:

  • free队列记录当前可用的worker
  • handles队列记录须要处理的TCP请求

当新请求到达的时候父进程将请求暂存handles队列,从free队列中出队一个worker,进入worker处理(handoff)阶段,关键逻辑实现以下:

RoundRobinHandle.prototype.distribute = function(err, handle) {
  this.handles.push(handle);
  const worker = this.free.shift();

  if (worker) {
    this.handoff(worker);
  }
};
复制代码

worker处理阶段首先从handles队列出队一个请求,而后经过进程通讯的方式通知子worker进行请求处理,当worker接收到通讯消息后发送ack信息,继续响应handles队列中的请求任务,当worker没法接受请求时,父进程负责从新调度worker进行处理。关键逻辑以下:

RoundRobinHandle.prototype.handoff = function(worker) {

  const handle = this.handles.shift();

  if (handle === undefined) {
    this.free.push(worker);  // Add to ready queue again.
    return;
  }

  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);
  });
};
复制代码

注意:主进程与子进程之间创建了IPC,所以主进程与子进程之间能够通讯,可是各个子进程之间是相互独立的(没法通讯)

参考资料

https://medium.com/@fermads/node-js-process-load-balancing-comparing-cluster-iptables-and-nginx-6746aaf38272