Node.js中的cluster模块深入解读
预备知识
在如今机器的CPU都是多核的背景下,Node的单线程设计已经没法更充分的"压榨"机器性能了。所以从v0.8开始,Node新增了一个内置模块——“cluster”,故名思议,它可以通过一个父进程管理一坨子进程的方式来实现集群的功能。
学习cluster之前,需要了解process相关的知识,如果不了解的话建议先阅读process模块、child_process模块。
cluster借助child_process模块的fork()方法来创建子进程,通过fork方式创建的子进程与父进程之间建立了IPC通道,支持双向通信。
cluster模块最早出现在node.jsv0.8版本中
为什么会存在cluster模块?
Node.js是单线程的,那么如果希望利用服务器的多核的资源的话,就应该多创建几个进程,由多个进程共同提供服务。如果直接采用下列方式启动多个服务的话,会提示端口占用。
consthttp=require('http');
http.createServer((req,res)=>{
res.writeHead(200);
res.end('helloworld\n');
}).listen(8000);
//启动第一个服务nodeindex.js&
//启动第二个服务nodeindex.js&
thrower;//Unhandled'error'event
^
Error:listenEADDRINUSE:::8000
atServer.setupListenHandle[as_listen2](net.js:1330:14)
atlistenInCluster(net.js:1378:12)
atServer.listen(net.js:1465:7)
atObject.(/Users/xiji/workspace/learn/node-basic/cluster/simple.js:5:4)
atModule._compile(internal/modules/cjs/loader.js:702:30)
atObject.Module._extensions..js(internal/modules/cjs/loader.js:713:10)
atModule.load(internal/modules/cjs/loader.js:612:32)
attryModuleLoad(internal/modules/cjs/loader.js:551:12)
atFunction.Module._load(internal/modules/cjs/loader.js:543:3)
atFunction.Module.runMain(internal/modules/cjs/loader.js:744:10)
如果改用cluster的话就没有问题
constcluster=require('cluster');
consthttp=require('http');
constnumCPUs=require('os').cpus().length;
if(cluster.isMaster){
console.log(`Master${process.pid}isrunning`);
//Forkworkers.
for(leti=0;i{
console.log(`worker${worker.process.pid}died`);
});
}else{
//WorkerscanshareanyTCPconnection
//InthiscaseitisanHTTPserver
http.createServer((req,res)=>{
res.writeHead(200);
res.end('helloworld\n');
}).listen(8000);
console.log(`Worker${process.pid}started`);
}
//nodeindex.js执行完启动了一个主进程和8个子进程(子进程数与cpu核数相一致)
Master11851isrunning
Worker11852started
Worker11854started
Worker11853started
Worker11855started
Worker11857started
Worker11858started
Worker11856started
Worker11859started
cluster是如何实现多进程共享端口的?
cluster创建的进程分两种,父进程和子进程,父进程只有一个,子进程有多个(一般根据cpu核数创建)
- 父进程负责监听端口接受请求,然后分发请求。
- 子进程负责请求的处理。
有三个问题需要回答:
- 子进程为何调用listen不会进行端口绑定
- 父进程何时创建的TCPServer
- 父进程是如何完成分发的
子进程为何调用listen不会绑定端口?
net.js源码中的listen方法通过listenInCluster方法来区分是父进程还是子进程,不同进程的差异在listenInCluster方法中体现
functionlistenInCluster(server,address,port,addressType,backlog,fd,excluseive){
if(cluster.isMaster||exclusive){
server._listen2(address,port,addressType,backlog,fd);
return;
}
constserverQuery={address:address......};
cluster._getServer(server,serverQuery,listenOnMasterHandle);
functionlistenOnMasterHandle(err,handle){
server._handle=handle;
server._listen2(address,port,addressType,backlog,fd);
}
}
上面是精简过的代码,当子进程调用listen方法时,会先执行_getServer,然后通过callback的形式指定server._handle的值,之后再调用_listen2方法。
cluster._getServer=function(obj,options,cb){
...
constmessage=util._extend({
act:'queryServer',
index:indexes[indexesKey],
data:null
},options);
message.address=address;
send(message,(reply,handle)=>{
if(handle)
shared(reply,handle,indexesKey,cb);//Sharedlistensocket.
else
rr(reply,indexesKey,cb);//Round-robin.
});
...
};
_getServer方法会向主进程发送queryServer的message,父进程执行完会调用回调函数,根据是否返回handle来区分是调用shared方法还是rr方法,这里其实是会调用rr方法。而rr方法的主要作用就是伪造了TCPWrapper来调用net的listenOnMasterHandle回调函数
functionrr(message,indexesKey,cb){
varkey=message.key;
functionlisten(backlog){
return0;
}
functionclose(){
if(key===undefined)
return;
send({act:'close',key});
deletehandles[key];
deleteindexes[indexesKey];
key=undefined;
}
functiongetsockname(out){
if(key)
util._extend(out,message.sockname);
return0;
}
consthandle={close,listen,ref:noop,unref:noop};
handles[key]=handle;
cb(0,handle);
}
由于子进程的server拿到的是围绕的TCPWrapper,当调用listen方法时并不会执行任何操作,所以在子进程中调用listen方法并不会绑定端口,因而也并不会报错。
父进程何时创建的TCPServer
在子进程发送给父进程的queryServermessage时,父进程会检测是否创建了TCPServer,如果没有的话就会创建TCPServer并绑定端口,然后再把子进程记录下来,方便后续的用户请求worker分发。
父进程是如何完成分发的
父进程由于绑定了端口号,所以可以捕获连接请求,父进程的onconnection方法会被触发,onconnection方法触发时会传递TCP对象参数,由于之前父进程记录了所有的worker,所以父进程可以选择要处理请求的worker,然后通过向worker发送act为newconn的消息,并传递TCP对象,子进程监听到消息后,对传递过来的TCP对象进行封装,封装成socket,然后触发connection事件。这样就实现了子进程虽然不监听端口,但是依然可以处理用户请求的目的。
cluster如何实现负载均衡
负载均衡直接依赖cluster的请求调度策略,在v6.0版本之前,cluster的调用策略采用的是cluster.SCHED_NONE(依赖于操作系统),SCHED_NODE理论上来说性能最好(FerandoMicalli写过一篇Node.js6.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);
constworker=this.free.shift();
if(worker){
this.handoff(worker);
}
};
worker处理阶段首先从handles队列出队一个请求,然后通过进程通信的方式通知子worker进行请求处理,当worker接收到通信消息后发送ack信息,继续响应handles队列中的请求任务,当worker无法接受请求时,父进程负责重新调度worker进行处理。关键逻辑如下:
RoundRobinHandle.prototype.handoff=function(worker){
consthandle=this.handles.shift();
if(handle===undefined){
this.free.push(worker);//Addtoreadyqueueagain.
return;
}
constmessage={act:'newconn',key:this.key};
sendHelper(worker.process,message,handle,(reply)=>{
if(reply.accepted)
handle.close();
else
this.distribute(0,handle);//Workerisshuttingdown.Sendtoanother.
this.handoff(worker);
});
};
注意:主进程与子进程之间建立了IPC,因此主进程与子进程之间可以通信,但是各个子进程之间是相互独立的(无法通信)
参考资料
https://medium.com/@fermads/node-js-process-load-balancing-comparing-cluster-iptables-and-nginx-6746aaf38272
总结
以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,如果有疑问大家可以留言交流,谢谢大家对毛票票的支持。