本文的目标在于阐述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都设置了该属性,那么就可以监听同一地址。