当前位置: 首页 > 后端技术 > Node.js

通过Node.jsCluster模块源码,深入PM2原理

时间:2023-04-03 17:30:40 Node.js

Node.js无疑是大前端全栈工程师技术栈的最快捷径(但你必须知道另一种后台语言,Golang推荐),虽然Node.js.js不能很好的做很多事情,但是在某些方面还是有它的优势的。众所周知,Node.js中的JavaScript代码是单线程执行的,非常脆弱。一旦出现未捕获的异常,整个应用就会崩溃。这在很多场景下是无法忍受的,尤其是在web应用中。通常的解决方案是使用Node.js自带的cluster模块,以master-worker模式启动多个应用实例。然而,在大家享受集群模块带来的好处的同时,很多人也开始疑惑1.为什么会有app.listen(port);在我的应用代码中,但是集群模块多次fork这段代码时,并没有报端口已经被占用?2、Master如何将接收到的请求传递给Worker进行处理,然后进行响应?带着这些疑问,我们开始往下看TIPS:本文写于2019年12月8日,是Node.js最新版本的源码Cluster源码分析:入口:constchildOrMaster='NODE_UNIQUE_ID'inprocess.env?'child':'master';module.exports=require(`internal/cluster/${childOrMaster}`);解析会判断当前一个Node_UNIQUE_ID(后面会讲到)是环境变量中的子进程还是master进程,然后引用不同的js代码,NODE_UNIQUE_ID是唯一标识。Node.js的Cluster多进程模式采用默认的调度算法是round-robin,其实就是轮询。官方的解释是练习效率很高,而且很稳定。问题一:为什么我的里面明明有app.listen(port);在应用代码中,但是集群模块多次fork这段代码时,没有报端口被占用?在Node.js官网上找到了答案:原来是所有的net.Sockets都设置为SO_REUSEADDR。什么是SO_REUSEADDR?为什么需要SO_REUSEADDR参数?服务器主动断开连接后,需要等待2个MSL,才能最终释放连接。重新启动后,它必须绑定到同一个端口。默认情况下,操作系统的实现会阻止新的监听套接字绑定到这个端口。优越的。我们都知道一个TCP连接是由一个四元组唯一确定的。形式如下{local-ip-address:local-port,foreign-ip-address:foreign-port}典型例子如下图所示。TCP要求这样的四元组必须是唯一的,但是大多数操作系统要求更严格,只要有连接使用这个本地端口,本地端口就不能被重用(bind调用失败)。启用SO_REUSEADDR套接字选项可以消除此限制。默认情况下,该值为0,表示关闭。在Java中,不同的JVM对reuseAddress有不同的实现。在我的本地机器上,此值默认为1以允许端口重用。但是为了保险起见,在写TCP和HTTP服务的时候一定要主动把这个参数设置为1。目前常见的网络编程模型是多进程或多线程。根据accpet的位置,分为以下两种场景:(1)单个进程或线程创建socket,并进行listen和accept,收到连接后创建进程和线程处理Connection(2)A单个进程或线程创建套接字并监听,并在同一个服务器套接字上预先创建多个工作进程或线程accept()。这两种模型充分发挥了多核CPU的优势,虽然可以做到线程和CPU核绑定,但是它们都存在:1.单个listenerworker进程或线程会成为高速连接的瓶颈访问处理2.多个线程竞争获取服务套接字3.缓存行跳转4.CPU间负载均衡难以实现。5.随着核心数量的扩大,性能并没有提升。6、SO_REUSEPORT解决什么问题?7.SO_REUSEPORT支持多个进程或线程绑定同一个端口,提高服务器程序性能解决的问题:1.允许多个套接字绑定()/监听()同一个TCP/UDP端口2.每个线程都有自己的服务器socket3.服务器上没有锁socket4.在内核层面实现负载均衡5.在安全层面,监听同一个端口的socket只能位于同一个用户下。核心实现有3个要点:1.扩展socket选项,增加SO_REUSEPORT选项设置reuseport2。修改bind系统调用的实现,支持绑定到同一个IP和端口。3.修改处理新连接的实现。在搜索监听器时,可以支持监听同一个IP的多个socks之间的平衡4.端口选择。5、有了SO_RESUEPORT,每个进程都可以创建自己的socket,bind,listen,accept相同的地址和端口,每一个都是独立的,平等的,允许多个进程监听同一个端口。每个进程中的acceptsocketfd都不一样,有新的。当连接建立时,内核只会唤醒一个进程接受,并保证唤醒的平衡。总结:原来是因为设置了SO_REUSEADDR,端口被复用了。当然,不止于此。下面继续介绍源码的第一行。什么是NODE_UNIQUE_ID?下面给出介绍:functioncreateWorkerProcess(id,env){//...workerEnv.NODE_UNIQUE_ID=''+id;//...returnfork(cluster.settings.exec,cluster.settings.args,{env:workerEnv,silent:cluster.settings.silent,execArgv:execArgv,gid:cluster.settings.gid,uid:cluster.settings.uid});}本来在创建子进程的时候,给每个进程一个唯一的自增ID,然后Node.js在初始化的时候会判断这个进程是否是从cluster模块fork出来的worker进程根据初始化时的环境变量。如果是就执行workerInit()函数初始化环境,否则执行masterInit()函数就是这行入口的代码~module.exports=require(`internal/cluster/${childOrMaster}`);接下来我们需要看一下net模块的listen函数的源码://lib/net.js//。..functionlisten(self,address,port,addressType,backlog,fd,exclusive){exclusive=!!exclusive;if(!cluster)cluster=require('cluster');if(cluster.isMaster||exclusive){self._listen2(address,port,addressType,backlog,fd);返回;}cluster._getServer(self,{address:地址,port:端口,addressType:addressType,fd:fd,flags:0},cb);functioncb(err,handle){//...self._handle=handle;self._listen2(地址,端口,地址类型,积压,fd);}}仔细一看,原来listen函数会根据是否是主进程执行不同的操作!上面提到了SO_REUSEADDR选项。在主进程调用的_listen2中,设置了各个初始化子进程的workerinit函数。还有一个cluster._getServer方法。你可能已经猜到了问题1的答案就在这个集群的._getServer函数的代码中。主要做了两件事:向master进程注册worker,如果master进程第一次收到监听这个端口/描述符的worker,会建立一个内部TCP服务器来承担监听这个端口/的任务descriptorResponsibilities,然后记录master中的worker。hack掉worker进程中net.Server实例监听端口/描述符的listen方法,让它不再承担这个职责。首先,由于master在接收和传递请求给worker时会满足一定的负载均衡规则(非Windows平台默认是轮询),所以这些逻辑都封装在RoundRobinHandle类中。因此,初始化内部TCP服务器等操作也在这里://lib/cluster.js//...functionRoundRobinHandle(key,address,port,addressType,backlog,fd){//...this.句柄=[];this.handle=null;this.server=net.createServer(assert.fail);if(fd>=0)this.server.listen({fd:fd});elseif(port>=0)this.server.listen(port,address);否则this.server.listen(地址);//UNIX套接字路径。///...}在子进程中:functionlisten(backlog){return0;}functionclose(){//...}functionref(){}functionunref(){}varhandle={close:close,listen:listen,ref:ref,unref:unref,}由于网络.Server实例的listen方法最终会调用自身_handle属性下的listen方法完成监听动作,所以在代码中修改:此时的listen方法已经被hack,每次调用只能返回0,并且不会监听Port//lib/net.js//...functionlisten(self,address,port,addressType,backlog,fd,exclusive){//...if(cluster.isMaster||exclusive){self._listen2(地址、端口、地址类型、积压、fd);返回;//仅在worker环境中发生变化}cluster._getServer(self,{地址:地址,端口:端口,addressType:addressType,fd:fd,flags:0},cb);functioncb(err,handle){//...self._handle=handle;//...}}这里可以看到传入回调函数中的handle重新定义了listen方法,返回0,所以子进程调用listen方法时,也返回0,不监听端口。至此,我恍然大悟原来是这样的。只有主进程一直在监听端口!通过上面近3000字的解释,把端口复用的问题说清楚了,下面也把负载均衡说清楚了。然后说一下PM2.0的原理实现。其实它只是封装了集群模式,功能很多。首先,绘制流程图。核心实现源码:functionRoundRobinHandle(key,address,port,addressType,backlog,fd){//...this.server=net.createServer(assert.fail);//...varself=this;this.server.once('listening',function(){//...self.handle.onconnection=self.distribute.bind(self);});}RoundRobinHandle.prototype.distribute=function(err,handle){this.handles.push(handle);varworker=this.free.shift();if(worker)this.handoff(worker);};RoundRobinHandle.prototype.handoff=function(worker){//...varmessage={act:'newconn',key:this.key};变种自我=这个;sendHelper(worker.process,message,handle,function(reply){//...});在handle对象中解析定义onconnection方法触发事件时,取出一个子进程通知,传入handle子进程收到消息和handle后,做相应的业务处理://lib/cluster.js//...//这个方法会在Node.js初始化时被src/node.js调用cluster._setupWorker=function(){//...process.on('internalMessage',internal(worker,onmessage));//...函数onm消息(消息,句柄){如果(消息。行为=='newconn')onconnection(消息,句柄);//...}};functiononconnection(message,handle){//...varaccepted=server!==undefined;//...if(accepted)server.onconnection(0,handle);}总结一下,负载均衡的大致流程:1.所有请求先经过内部TCP服务器,只有真正的监听端口mainprocess2.在内部TCP服务器的请求处理逻辑中,通过负载均衡的方式选出一个worker进程,向其发送newconn内部消息,并随消息一起发送clienthandle。3、Worker进程收到这个内部消息,根据clienthandle创建一个net.Socket实例,执行具体的业务逻辑,返回。至此,关于Cluster多进程模式和负载均衡的讲解完毕。下面是PM2的实现原理,基于使用Cluster模式封装PM2:npmipm2-gpm2startapp.jspm2ls这样就可以启动你的Node了。js服务,并根据你电脑的CPU数量启动相应数量的进程,监控错误事件,并重启子进程。即使代码有更新,需要热更新,也会一一更换,号称永动机。其功能:1.内置负载均衡(使用Node集群集群模块)2.后台运行3.0秒关机重载。我理解的意思就是在维护升级的时候不用停机。4.它有Ubuntu和CentOS的启动脚本5.停止不稳定的进程(避免无限循环)6.控制台检测7.提供HTTPAPI8.远程控制和实时接口API(Nodejs模块,允许与PM2进程管理器交互)先来一张pm2的架构图:pm2包含了Satan进程、GodDeamon守护进程、进程间远程调用rpc、集群等几个概念。主要是指《圣经》中的堕落天使(又名堕落天使撒旦),被视为与神的力量相对的邪恶与黑暗之源,与神相对立。1.Satan.js提供了程序退出、查杀等方法,所以是恶魔;God.js负责维护进程的正常运行,在异常退出的时候可以保证重启,所以是神。作者这么命名,我只能说一句:天哪。God进程从启动开始就一直在运行。相当于集群中的Master进程,守护worker进程的正常运行。2.rpc(RemoteProcedureCallProtocol)是指远程过程调用,也就是说,两台服务器A和B,一个部署在服务器A上的应用程序,要调用服务器B上的应用程序提供的功能/方法,因为它不在同一个内存空间不能直接调用,需要表达调用的语义,通过网络传递调用的数据。同一台机器上不同进程之间的方法调用也属于rpc的范畴。3.代码中用到了axon-rpc和axon两个库。基本原理是提供服务的服务器绑定一个域名和端口,调用服务的客户端连接端口,实现rpc连接。后续新版本采用了pm2-axon-rpc和pm2-axon库,绑定方式也由端口改为.sock文件,因为端口可能与现有进程的端口冲突。执行过程程序的执行流程图如下:每次输入命令行都会执行撒旦程序。如果上帝进程没有运行,首先需要启动上帝进程。然后撒旦根据指令,通过rpc调用God中相应的方法,执行相应的逻辑。以pm2startapp.js-i4为例,上帝会在第一次执行时配置集群,同时监听集群中的事件://configureclustercluster.setupMaster({exec:path.resolve(path.dirname(module.filename),'ProcessContainer.js')});//监听集群事件(functioninitEngine(){cluster.on('online',function(clu??){//Theworker进程正在执行god.clusters_db[clu.pm_id].status='online';});//命令行中killpid会触发退出事件,process.kill不会触发退出cluster.on('exit',function(clu??,code,signal){//重启进程如果重启次数太频繁,直接标记为stoppedGod.clusters_db[clu.pm_id].status='starting';//逻辑...});})();God启动后,Satan会和God建立rpc链接,然后调用prepare方法。prepare方法会调用cluster.fork完成集群的启动God.prepare=function(opts,cb){...returnexecute(opts,cb);};functionexecute(env,cb){...varclu=cluster.fork(env);...God.clusters_db[id]=clu;clu.once('online',function(){God.clusters_db[id].status='online';if(cb)returncb(null,clu);returntrue;});returnclu;}pm2目前功能很多,源码阅读很费时间,但是可以猜到一些功能的实现:比如如何检测孩子是否进程是否正常Active状态?心跳检测用于每隔几秒向子进程发送心跳包。如果子进程没有回复,则调用kill杀死进程,然后重新cluster.fork()一个新进程。子进程发送异常错误。如何保证总是有一定数量的子进程?子进程可以监听错误事件。这时可以向主进程发送消息,请求kill自己,此时主进程调用cluster.fork一个新的子进程。目前很多Node.js服务都是依赖Nginx+pm2+Docker来实现自动化+监控部署。PM2本身也有监控系统,分为免费版和付费版。具体可以查看官网和搜索一些操作手册进行监控操作。配置比较简单,这里就不做概述了。https://pm2.keymetrics.io/如果觉得写的不错,请点个赞,分享给身边的很多人。原创不易,需要支持~!欢迎关注微信公众号:前端高峰尽量都是原创内容,回复群即可加入众多小姐姐前端交流群~