ThinkJS
首发于ThinkJS

细谈 ThinkJS 多进程模型

Node.js 的单个实例在单个线程中运行,如果要利用多核 CPU 的系统,就需要启动一个 Node.js 进程集群来处理负载。ThinkJS 框架使用 think-cluster 模块实现集群,本文主要讲解 think-cluster 的分配策略和功能。

分配策略

Node.js 官方实现的 cluster 模块有两种分配策略,一种是基于 Round-robin 算法的循环分配策略,它是除了Windows 以外其他操作系统默认的分配策略。另一种无分配策略,由 master 进程创建侦听套接字,将请求发送给感兴趣的 worker 进程,抢到本次请求的 worker 进程负责处理。理论上第二种分配策略应该是性能最好的,然而在实践中,由于操作系统调度器的变幻莫测,负载分配非常不平衡,例如一个服务启动了8个 worker 进程,可能负载超过70%的进程只有两个,其它的都闲着没啥事干。

cluster 模块上有个全局属性 cluster.schedulingPolicy,它的可用值是cluster.SCHED_RRcluster.SCHED_NONE,分别代表 Round-robin 分配策略和无分配策略,一旦使用 fork 创建了 worker 进程或者调用了cluster.setupMaster()方法,该属性将无法修改。它还可以通过环境变量NODE_CLUSTER_SCHED_POLICY设置,环境变量的可选值为rrnone

无分配策略

首先 master 进程通过 fork 创建4个 worker 进程(实际上worker的数量需要要根据你的服务器配置和业务需要来决定), worker 进程自己去监听端口,接受并处理请求,这样会产惊群现象,举一个很简单的例子,当你往鱼群中间扔一块面包,虽然最终只有一条鱼抢到食物,但所有鱼都会被惊动来争夺,没有抢到食物的鱼只好散去, 等待下一块食物到来。这样每扔一块食物,都会惊动所有的鱼,即为惊群现象。对于操作系统来说,多个进程/线程在等待同一资源时,也会产生类似的效果,其结果就是每当资源可用,所有的进程/线程都来竞争资源,造成如下后果:

  • 系统对用户进程/线程频繁的做无效的调度、上下文切换,系统性能大打折扣。
  • 为了确保只有一个线程得到资源,用户必须对资源操作进行加锁保护,进一步加大了系统开销。

惊群效应常见的场景是 socket 进行的 accept 操作。当多个用户进程/线程同时监听同一个端口时,由于实际上一个请求过来,只有一个进程/线程 accept 成功,所以就会产生惊群效应。由于无法控制一个新的连接由哪个进程来处理,必然导致各 worker 进程之间的负载非常不均衡,因此这种配策略比 Round-robin 分配策略效率更低了。

转载自http://taobaofed.org/blog/2015/11/03/nodejs-cluster/

代码如下:

// master.js
const net = require('net');
const fork = require('child_process').fork;

const WORKER_NUMBER = 4;
const PORT = 3000;

const handle = net._createServerHandle('0.0.0.0', PORT);

for (let i = 0; i < WORKER_NUMBER; i++) {
  fork('./worker').send({}, handle);
}

// worker.js
const net = require('net');

process.on('message', function(m, handle) {
  start(handle);
});

var buf = 'hello nodejs';
var res = ['HTTP/1.1 200 OK', 'content-length:' + buf.length].join('\r\n') + '\r\n\r\n' + buf;

function start(server) {
  // worker 进程监听
  server.listen();
  server.onconnection = function(err, handle) {
    console.log('got a connection on worker, pid = %d', process.pid);
    var socket = new net.Socket({
      handle,
    });
    socket.readable = socket.writable = true;
    socket.end(res);
  };
  server.onerror = function() {};
}

Round-robin 分配策略

首先 master 进程通过 fork 创建4个 worker 进程,套接字绑定 ip 和 port 然后监听。当接受到请求时,由 master 决定将该请求分配给哪个 worker 进程,dispatch 部分可以是 Round-robin 算法实现的循环分配策略,也可以是下文中要讲的 sticky 分配策略。

转载自http://taobaofed.org/blog/2015/11/03/nodejs-cluster/

代码实现如下:

// master.js
const net = require('net');
const fork = require('child_process').fork;

const WORKER_NUMBER = 4;
const PORT = 3000;

const workers = [];
for (let i = 0; i < WORKER_NUMBER; i ++) {
  workers.push(fork('./worker'));
}

const handle = net._createServerhandle('0.0.0.0', PORT);
// master 进程监听
handle.listen();

handle.onconncetion = function(err, handle) {
  // 模拟队列FIFO进行循环分配
  const worker = workers.pop();
  worker.send({}, handle);
  workers.unshift(worker);
}

// worker.js
const net = require('net');

process.on('message', (m, handle) => {
  start(handle);
})

var buf = 'hello nodejs';
var res = ['HTTP/1.1 200 OK', 'content-length:' + buf.length].join('\r\n') + '\r\n\r\n' + buf;

function start(handle) {
  console.log('got a connection on worker, pid = %d', process.pid);
  var socket = new net.Socket({
    handle,
  });
  socket.readable = socket.writable = true;
  socket.end(res);
}

sticky 分配策略

sticky 是 think-cluster 实现的粘性分配策略,在集群环境中,WebSocket 要求使用粘性会话,来确保给定客户端请求命中相同的 worker,否则其握手机制将无法正常工作。 粘性分配策略将来自同一客户端的请求分配给同一个 worker 进程。在 ThinkJS 的配置中通过stickyCluster: true启用这个策略,该配置默认是关闭的,它比 Round-robin 分配策略性能差些。

代码如下:

// master.js
const net = require('net');
const fork = require('child_process').fork;
const stringHash = require('string-hash');

const WORKER_NUMBER = 4;
const PORT = 3000;

const workers = [];
for (let i = 0; i < WORKER_NUMBER; i ++) {
  workers.push(fork('./worker'));
}

const handle = net._createServerhandle('0.0.0.0', PORT);
// master 进程监听
handle.listen();

handle.onconncetion = function(err, handle) {
  // 确保相同地址每次都得到相同index
  const index = stringHash(handle.remoteAddress) % workers.length;
  let idx = -1;
  workers.forEach(worker => {
    if (index == ++idx) {
      worker.send({}, handle)
    }
  })
}

// worker.js
const net = require('net');

process.on('message', (m, handle) => {
  start(handle);
})

var buf = 'hello nodejs';
var res = ['HTTP/1.1 200 OK', 'content-length:' + buf.length].join('\r\n') + '\r\n\r\n' + buf;

function start(handle) {
  console.log('got a connection on worker, pid = %d', process.pid);
  var socket = new net.Socket({
    handle,
  });
  socket.readable = socket.writable = true;
  socket.end(res);
}

think-cluster 的功能

think-cluster 实现了自定义 Master、Worker 类和进程间通信模块 Messenger 类,用来实现负载均衡、优雅退出、进程守护、进程间通信四项功能。

负载均衡

ThinkJS 开发环境默认创建1个 worker 进程,生产环境启动和系统逻辑 CPU 数量相同的 worker 进程。ThinkJS 通过刚才讲述的几个分配策略实现负载均衡。

优雅退出

Node.js 稳定性较差,如果进程发生未捕获的异常就会挂掉,线上项目也不可避免的存在bug和异常,导致进程挂掉。如果让进程简单粗暴的挂掉,必定导致该 woker 进程所有请求全部丢失,给用户带来糟糕的体验,因此 ThinkJS 必然要实现进程的优雅退出。

让 worker 进程监听uncaughtExceptionunhandledRejection事件,避免进程直接退出。在监听到事件时,先创建一个新的 worker 进程保证系统负载,然后调用自定义的closeServer方法,停止接受新的连接并处理完当前连接,在几秒后请求差不多处理完后(这个时间可以通过配置processKillTimeout来设置,默认时长10秒),该进程彻底退出。

这里面有一个小的细节,调用closeServer后首先要处理的是关闭后续请求的 keep-alive,告诉客户端不要保持 socket 连接了。

代码如下:

/**
 * disable keep alive
 */
disableKeepAlive() {
  if (this[KEEP_ALIVE]) return;
  this[KEEP_ALIVE] = true;
  this.server.on('request', (req, res) => {
    req.shouldKeepAlive = false;
    res.shouldKeepAlive = false;
    if (!res.headersSent) {
      res.setHeader('Connection', 'close');
    }
  });
}
/**
 * close server
 */
closeServer() {
  // 关闭新请求的keep-alive
  this.disableKeepAlive();
  // 设置退出定时器
  const killTimeout = this.options.processKillTimeout;
  if (killTimeout) {
    const timer = setTimeout(() => {
      debug(`process exit by killed(timeout: ${killTimeout}ms), pid: ${process.pid}`);
      process.exit(1);
    }, killTimeout);
    timer.unref && timer.unref();
  }
  const worker = cluster.worker;
  debug(`start close server, pid: ${process.pid}`);
  // 关闭服务
  this.server.close(() => {
  debug(`server closed, pid: ${process.pid}`);
    try {
      worker.disconnect();
    } catch (e) {
      debug(`already disconnect, pid:${process.pid}`);
    }
  });
}
/**
 * disconnect worker
 * @param {Boolean} sendSignal
 */
disconnectWorker(sendSignal) {
  const worker = cluster.worker;
  // if worker has diconnect, return directly
  if (worker[WORKER_RELOAD]) return;
  worker[WORKER_RELOAD] = true;
  if (sendSignal) {
    // 发送信号以启动新的 worker 进程
    worker.send(util.THINK_GRACEFUL_DISCONNECT);
    // 新进程启动成功后,执行关闭当前 worker 进程
    worker.once('message', message => {
      if (message === util.THINK_GRACEFUL_FORK) {
        this.closeServer();
      }
    });
  } else {
    this.closeServer();
  }
}

守护进程

master 进程的功能有两个,一个是根据策略分配请求,第二个是监听到重启信号时进行平滑重启。开发时代码更新和服务上线都需要平滑重启,master 进程收到重启信号后,它会遍历所有 worker 进程杀一个起一个。全部干掉再重启是不可能的,那样就没人干活了,就不叫平滑重启了。

/**
 * capture reload signal
 */
captureReloadSignal() {
  const signal = this.options.reloadSignal;
  const reloadWorkers = () => {
    // 杀死旧进程,新建 worker 进程
    util.getAliveWorkers().forEach(worker => worker.send(util.THINK_RELOAD_SIGNAL));
  };
  // 默认信号是 USR2
  if (signal) process.on(signal, reloadWorkers);
  // if receive message `think-cluster-reload-workers` from worker, restart all workers
  cluster.on('message', (worker, message) => {
    if (message !== 'think-cluster-reload-workers') return;
    reloadWorkers();
  });
}

进程间通信

这个部分 ThinkJS 官网写的很详细了,详细请看多进程 - ThinkJS 文档

最后

think-cluster 代码并不多,感兴趣可以去看看,如果有问题可以在评论区留言,或者加入 ThinkJS 官方群提问:339337680。

参考链接:

编辑于 2018-06-10

文章被以下专栏收录

    ThinkJS 是一款基于 Koa 2.0 面向未来的企业级 Node.js 框架,致力于整合了大量的项目最佳实践,让企业级开发变得如此简单、高效。本专栏会分享一些在 ThinkJS 项目开发过程中总结的一些经验以及问题,同时也非常欢迎大家投稿。