node集群的实现:cluster或shared sockets

1,042 阅读5分钟

本文的目标在于阐述node在实现cluster和使用child_process来构建单机集群上的底层原理,并对二者之间的异同加以比较。

先说说使用child_process实现shared sockets

在《深入浅出Node.js》一书中,作者花了较大篇幅来实现一个基于child_process的单机集群,实现的代码主要如下:

master.js

var net = require('net');
var cp = require('child_process');
var w1 = cp.fork('./singletest/worker.js');
var w2 = cp.fork('./singletest/worker.js');
var w3 = cp.fork('./singletest/worker.js');
var w4 = cp.fork('./singletest/worker.js');
​
var server = net.createServer();
​
server.listen(8000,function(){
 // 传递句柄
 w1.send({type: 'handle'},server);
 w2.send({type: 'handle'},server);
 w3.send({type: 'handle'},server);
 w4.send({type: 'handle'},server);
 server.close();
});

child.js

var server = require('http').createServer(function(req,res){
  res.write(cluster.isMaster + '');
  res.end(process.pid+'')
})

var cluster = require('cluster');
process.on('message',(data,handle)=>{
  if(data.type !== 'handle')
    return;

  handle.on('connection',function(socket){
    server.emit('connection',socket)
  });
});

可以看到这里看起来就像是把主进程创建的服务器发送给了子进程,事实又是如何呢?

node进程间通信IPC采用的是libuv来实现,具体的实现我们不用深入,只需要明确一点:这里发送的server句柄包含服务端socket的文件描述符,node的child_process模块对此句柄会有特殊处理,并不是真正的发送了服务器给子进程,具体的处理是利用该句柄还原出一个tcp server,在子进程中启动,并监听句柄中的socket文件描述符,也就是说每一个子进程都启动了一个服务器,只不过他们监听的是相同socket文件描述符。这就是shared sockets模型。

子进程还原server的过程
// handle: server句柄
function(message, handle, emit) {
	var self = this;
	var servr = new net.Server();
	server.listen(handle, function() {
		emit(server);
	})
}

现在我们就得到了下面的单机集群,一些监听相同socket文件描述符的不同进程。

那么每次由哪个进程来处理呢?这就依赖于操作系统来分配给哪一个监听该文件描述符的socket了。

可能已经有人发现问题:这样的话,不就是意味着多个进程在监听着同一个端口了吗?没错,但是node底层设置了socket的SO_REUSEADDR属性(向后翻查看),也就意味着允许不同的socket监听同一个端口,不会发生冲突。

cluster

cluster不是简单地对上述使用shared sockets进行了封装,原因就是shared sockets对进程的调度依赖于操作系统,而操作系统为了高效,避免上下文的切换,往往偏向于对一个进程的重复使用。

cluster的简单使用
const cluster = require('cluster');
const http = require('http');

if (cluster.isMaster) {

  let numReqs = 0;
  setInterval(() => {
    console.log(`numReqs = ${numReqs}`);
  }, 1000);

  function messageHandler(msg) {
    if (msg.cmd && msg.cmd === 'notifyRequest') {
      numReqs += 1;
    }
  }

  const numCPUs = require('os').cpus().length;
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  for (const id in cluster.workers) {
    cluster.workers[id].on('message', messageHandler);
  }

} else {

  // Worker processes have a http server.
  http.Server((req, res) => {
    res.writeHead(200);
    res.end('hello world\n');

    process.send({ cmd: 'notifyRequest' });
  }).listen(8000);
}

上面的代码利用cluster实现了一个单机集群,看起来好像和shared sockets模型类似,父进程启动了n个子进程,子进程都监听了8000端口。然而在node的世界里,因为高度的封装,往往看到的不是真实的。

感兴趣的可以去阅读cluster的源码,这里直接上结论:

核心代码的入口是http.Server().listen(8000),之后,主进程会创建tcp服务监听端口,而子进程也会创建服务,但是不创建socket去监听端口,而是监听一个"伪造"的句柄;当触发连接事件的时候,主进程会将socket通过IPC发给子进程,子进程得到那个"伪造"的句柄。(能够把进程、socket、tcp分开理解是关键)。

这样,分发策略就能够掌握在我们的手中了,node在linux系统上的默认分发策略是round-robin(也就是有规律的轮流分派)。

最终的cluster模型长这样

总结

至此,shared sockets和cluster模型就简述得差不多了。

  • Shared sockets启动了多个进程监听同一个socket文件描述符,由操作系统分发;

  • cluster仅仅只有主进程监听端口,按照策略分发socket给子进程。

补充知识(last but not least)

什么是socket?

通常,我们说socket(套接字)是TCP连接的一端,TCP/IP是一个协议,socket编程可以简单理解为操作系统实现的调用TCP协议的接口。

但是,socket和TCP/IP没有必然的联系,应用程序可以创建socket,然后利用socket编程,让socket监听指定的地址(host:port对)。node的cluster模块中,子进程就虽然创建了net.Server,但是没有创建socket去监听端口。

什么是句柄(handle)?

handle英文意思是把手,顾名思义,要想知道是一个锤子,还是一把铁锹,得看那头是什么。

句柄就是对某种资源的引用,是一个在应用程序层面的抽象概念,具体指向什么,一般要看句柄中的内容是什么,句柄中可能是socket的文件描述符,也可以是其他的。在node child_process构建的集群中,send('{type: 'handle'},server')这里的server就是指向一个tcp服务的句柄,其中包含着服务相关的信息,比如socket的文件描述符。

socket的SO_REUSEADDR和SO_REUSEPORT

首先要再次明确,socket由应用程序创建,可以让它监听某一个地址(protocol, client host, client port, server host, server port构成一个地址)。

一般情况下,一个地址被socketA监听,将不允许被socketB监听。

但是设置了socket的SO_REUSEADDR之后,如果socketA处于TIME_WAIT(TCP连接的一种状态,更多细节就不再深入了)状态,将会允许socketB监听该地址,简言之,可以允许socketA和socketB监听同一地址。

SO_REUSEPORT相对容易理解,只要socketA和socketB都设置了该属性,那么就可以监听同一地址。