深入理解NodeJS 多进程和集群
进程和线程
“进程”是计算机系统进行资源分配和调度的基本单位,我们可以理解为计算机每开启一个任务就会创建至少一个进程来处理,有时会创建多个,如Chrome浏览器的选项卡,其目的是为了防止一个进程挂掉而应用停止工作,而“线程”是程序执行流的最小单元,NodeJS默认是单进程、单线程的,我们将这个进程称为主进程,也可以通过child_process模块创建子进程实现多进程,我们称这些子进程为“工作进程”,并且归主进程管理,进程之间默认是不能通信的,且所有子进程执行任务都是异步的。
spawn实现多进程
1、spawn创建子进程
在NodeJS中执行一个JS文件,如果想在这个文件中再同时(异步)执行另一个JS文件,可以使用child_process模块中的spawn来实现,spawn可以帮助我们创建一个子进程,用法如下。
//文件:process.js const{spawn}=require("child_process"); constpath=require("path"); //创建子进程 letchild=spawn("node",["sub_process.js","--port","3000"],{ cwd:path.join(__dirname,"test")//指定子进程的当前工作目录 }); //出现错误触发 child.on("error",err=>console.log(err)); //子进程退出触发 child.on("exit",()=>console.log("exit")); //子进程关闭触发 child.on("close",()=>console.log("close")); //exit //close
spawn方法可以帮助我们创建一个子进程,这个子进程就是方法的返回值,spawn接收以下几个参数:
- command:要运行的命令;
- args:类型为数组,数组内第一项为文件名,后面项依次为执行文件的命令参数和值;
- options:选项,类型为对象,用于指定子进程的当前工作目录和主进程、子进程的通信规则等,具体可查看官方文档。
error事件在子进程出错时触发,exit事件在子进程退出时触发,close事件在子进程关闭后触发,在子进程任务结束后exit一定会触发,close不一定触发。
//文件:~test/sub_process.js //打印子进程执行sub_process.js文件的参数 console.log(process.argv);
通过上面代码打印了子进程执行时的参数,但是我们发现主进程窗口并没有打印,我们希望的是子进程的信息可以反馈给主进程,要实现通信需要在创建子进程时在第三个参数options中配置stdio属性定义。
2、spawn定义输入、输出
//文件:process.js const{spawn}=require("child_process"); constpath=require("path"); //创建子进程 letchild=spawn("node",["sub_process.js","--port","3000"],{ cwd:path.join(__dirname,"test")//指定子进程的当前工作目录 //stdin:[process.stdin,process.stdout,process.stderr] stdio:[0,1,2]//配置标准输入、标准输出、错误输出 }); //C:\ProgramFiles\nodejs\node.exe,g:\process\test\sub_process.js,--port,3000
//文件:~test/sub_process.js //使用主进程的标准输出,输出sub_process.js文件执行的参数 process.stdout.write(process.argv.toString());
通过上面配置options的stdio值为数组,上面的两种写法作用相同,都表示子进程和主进程共用了主进程的标准输入、标准输出、和错误输出,实际上并没有实现主进程与子进程的通信,其中0和stdin代表标准输入,1和stdout代表标准输出,2和stderr代表错误输出。
上面这样的方式只要子进程执行sub_process.js就会在窗口输出,如果我们希望是否输出在主进程里面控制,即实现子进程与主进程的通信,看下面用法。
//文件:process.js const{spawn}=require("child_process"); constpath=require("path"); //创建子进程 letchild=spawn("node",["sub_process.js"],{ cwd:path.join(__dirname,"test"), stdio:["pipe"] }); child.stdout.on("data",data=>console.log(data.toString())); //helloworld
//文件:~test/sub_process.js //子进程执行sub_process.js process.stdout.write("helloworld");
上面将stdio内数组的值配置为pipe(默认不写就是pipe),则通过流的方式实现主进程和子进程的通信,通过子进程的标准输出(可写流)写入,在主进程通过子进程的标准输出通过data事件读取的流在输出到窗口(这种写法很少用),上面都只在主进程中开启了一个子进程,下面举一个开启多个进程的例子。
例子的场景是主进程开启两个子进程,先运行子进程1传递一些参数,子进程1将参数取出返还给主进程,主进程再把参数传递给子进程2,通过子进程2将参数写入到文件param.txt中,这个过程不代表真实应用场景,主要目的是体会主进程和子进程的通信过程。
//文件:process.js const{spawn}=require("child_process"); constpath=require("path"); //创建子进程 letchild1=spawn("node",["sub_process_1.js","--port","3000"],{ cwd:path.join(__dirname,"test"), }); letchild2=spawn("node",["sub_process_2.js"],{ cwd:path.join(__dirname,"test"), }); //读取子进程1写入的内容,写入子进程2 child1.stdout.on("data",data=>child2.stdout.write(data.toString));
//文件:~test/sub_process_1.js //获取--port和3000 process.argv.slice(2).forEach(item=>process.stdout.write(item));
//文件:~test/sub_process_2.js constfs=require("fs"); //读取主进程传递的参数并写入文件 process.stdout.on("data",data=>{ fs.writeFile("param.txt",data,()=>{ process.exit(); }); });
有一点需要注意,在子进程2写入文件的时候,由于主进程不知道子进程2什么时候写完,所以主进程会卡住,需要子进程在写入完成后调用process.exit方法退出子进程,子进程退出并关闭后,主进程会随之关闭。
在我们给options配置stdio时,数组内其实可以对标准输入、标准输出和错误输出分开配置,默认数组内为pipe时代表三者都为pipe,分别配置看下面案例。
//文件:process.js const{spawn}=require("spawn"); constpath=require("path"); //创建子进程 letchild=spawn("node",["sub_process.js"],{ cwd:path.join(__dirname,"test"), stdio:[0,"pipe",2] }); //world
//文件:~test/sub_process.js console.log("hello"); console.error("world");
上面代码中对stderr实现了默认打印而不通信,对标准输入实现了通信,还有一种情况,如果希望子进程只是默默的执行任务,而在主进程命令窗口什么类型的输出都禁止,可以在数组中对应位置给定值ignore,将上面案例修改如下。
//文件:process.js const{spawn}=require("spawn"); constpath=require("path"); //创建子进程 letchild=spawn("node",["sub_process.js"],{ cwd:path.join(__dirname,"test"), stdio:[0,"pipe","ignore"] });
//文件:~test/sub_process.js console.log("hello"); console.error("world");
这次我们发现无论标准输出和错误输出都没有生效,上面这些方式其实是不太方便的,因为输出有stdout和stderr,在写法上没办法统一,可以通过下面的方式来统一。
3、标准进程通信
//文件:process.js const{spawn}=require("spawn"); constpath=require("path"); //创建子进程 letchild=spawn("node",["sub_process.js"],{ cwd:path.join(__dirname,"test"), stdio:[0,"pipe","ignore","ipc"] }); child.on("message",data=>{ console.log(data); //回复消息给子进程 child.send("world"); //杀死子进程 //process.kill(child.pid); }); //hello
//文件:~test/sub_process.js //给主进程发送消息 process.send("hello"); //接收主进程回复的消息 process.on("message",data=>{ console.log(data); //退出子进程 process.exit(); }); //world
这种方式被称为标准进程通信,通过给options的stdio数组配置ipc,只要数组中存在ipc即可,一般放在数组开头或结尾,配置ipc后子进程通过调用自己的send方法发送消息给主进程,主进程中用子进程的message事件进行接收,也可以在主进程中接收消息的message事件的回调当中,通过子进程的send回复消息,并在子进程中用message事件进行接收,这样的编程方式比较统一,更贴近于开发者的意愿。
4、退出和杀死子进程
上面代码中子进程在接收到主进程的消息时直接退出,也可以在子进程发送给消息给主进程时,主进程接收到消息直接杀死子进程,代码如下。
//文件:process.js const{spawn}=require("spawn"); constpath=require("path"); //创建子进程 letchild=spawn("node",["sub_process.js"],{ cwd:path.join(__dirname,"test"), stdio:[0,"pipe","ignore","ipc"] }); child.on("message",data=>{ console.log(data); //杀死子进程 process.kill(child.pid); }); //helloworld
//文件:~test/sub_process.js //给主进程发送消息 process.send("hello");
从上面代码我们可以看出,杀死子进程的方法为process.kill,由于一个主进程可能有多个子进程,所以指定要杀死的子进程需要传入子进程的pid属性作为process.kill的参数。
{%notewarning%}
注意:退出子进程process.exit方法是在子进程中操作的,此时process代表子进程,杀死子进程process.kill是在主进程中操作的,此时process代表主进程。
{%endnote%}
5、独立子进程
我们前面说过,child_process模块创建的子进程是被主进程统一管理的,如果主进程挂了,所有的子进程也会受到影响一起挂掉,但其实使用多进程一方面为了提高处理任务的效率,另一方面也是为了当一个进程挂掉时还有其他进程可以继续工作,不至于整个应用挂掉,这样的例子非常多,比如Chrome浏览器的选项卡,比如VSCode编辑器运行时都会同时开启多个进程同时处理任务,其实在spawn创建子进程时,也可以实现子进程的独立,即子进程不再受主进程的控制和影响。
//文件:process.js const{spawn}=require("spawn"); constpath=require("path"); //创建子进程 letchild=spawn("node",["sub_process.js"],{ cwd:path.join(__dirname,"test"), stdio:"ignore", detached:true }); //与主进程断绝关系 child.unref();
//文件:~test/sub_process.js constfs=require("fs"); setInterval(()=>{ fs.appendFileSync("test.txt","hello"); });
要想创建的子进程独立,需要在创建子进程时配置detached参数为true,表示该子进程不受控制,还需调用子进程的unref方法与主进程断绝关系,但是仅仅这样子进程可能还是会受主进程的影响,要想子进程完全独立需要保证子进程一定不能和主进程共用标准输入、标准输出和错误输出,也就是stdio必须设置为ignore,这也就代表着独立的子进程是不能和主进程进行标准进程通信,即不能设置ipc。
fork实现多进程
1、fork的使用
fork也是child_process模块的一个方法,与spawn类似,是在spawn的基础上又做了一层封装,我们看一个fork使用的例子。
//文件:process.js constfork=require("child_process"); constpath=require("path"); //创建子进程 letchild=fork("sub_process.js",["--port","3000"],{ cwd:path.join(__dirname,"test"), silent:true }); child.send("helloworld");
//文件:~test/sub_process.js //接收主进程发来的消息 process.on("message",data=>console.log(data));
fork的用法与spawn相比有所改变,第一个参数是子进程执行文件的名称,第二个参数为数组,存储执行时的参数和值,第三个参数为options,其中使用slilent属性替代了spawn的stdio,当silent为true时,此时主进程与子进程的所有非标准通信的操作都不会生效,包括标准输入、标准输出和错误输出,当设为false时可正常输出,返回值依然为一个子进程。
fork创建的子进程可以直接通过send方法和监听message事件与主进程进行通信。
2、fork的原理
其实fork的原理非常简单,只是在子进程模块child_process上挂了一个fork方法,而在该方法内调用spawn并将spawn返回的子进程作为返回值返回,下面进行简易实现。
//文件:fork.js constchildProcess=require("child_process"); constpath=require("path"); //封装原理 childProcess.fork=function(modulePath,args,options){ letstdio=options.silent?["ignore","ignore","ignore","ipc"]:[0,1,2,"ipc"]; returnchildProcess.spawn("node",[modulePath,...args],{ ...options, stdio }); } //创建子进程 letchild=fork("sub_process.js",["--port","3000"],{ cwd:path.join(__dirname,"test"), silent:false }); //向子进程发送消息 child.send("helloworld");
//文件:~test/sub_process.js //接收主进程发来的消息 process.on("message",data=>console.log(data)); //helloworld
spawn中的有一些fork没有传的参数(如使用node执行文件),都在内部调用spawn时传递默认值或将默认参数与fork传入的参数进行整合,着重处理了spawn没有的参数silent,其实就是处理成了spawn的stdio参数两种极端的情况(默认使用ipc通信),封装fork就是让我们能更方便的创建子进程,可以更少的传参。
execFile和exec实现多进程
execFile和exec是child_process模块的两个方法,execFile是基于spawn封装的,而exec是基于execFile封装的,这两个方法用法大同小异,execFile可以直接创建子进程进行文件操作,而exec可以直接开启子进程执行命令,常见的应用场景如http-server以及weboack-dev-server等命令行工具在启动本地服务时自动打开浏览器。
//execFile和exec const{execFile,exec}=require("child_process"); letexecFileChild=execFile("node",["--version"],(err,stdout,stderr)=>{ if(error)throwerror; console.log(stdout); console.log(stderr); }); letexecChild=exec("node--version",(err,stdout,stderr)=>{ if(err)throwerr; console.log(stdout); console.log(stderr); });
exec与execFile的区别在于传参,execFile第一个参数为文件的可执行路径或命令,第二个参数为命令的参数集合(数组),第三个参数为options,最后一个参数为回调函数,回调函数的形参为错误、标准输出和错误输出。
exec在传参上将execFile的前两个参数进行了整合,也就是命令与命令参数拼接成字符串作为第一参数,后面的参数都与execFile相同。
cluster集群
开启进程需要消耗内存,所以开启进程的数量要适合,合理运用多进程可以大大提高效率,如Webpack对资源进行打包,就开启了多个进程同时进行,大大提高了打包速度,集群也是多进程重要的应用之一,用多个进程同时监听同一个服务,一般开启进程的数量跟CPU核数相同为好,此时多个进程监听的服务会根据请求压力分流处理,也可以通过设置每个子进程处理请求的数量来实现“负载均衡”。
1、使用ipc实现集群
ipc标准进程通信使用send方法发送消息时第二个参数支持传入一个服务,必须是http服务或者tcp服务,子进程通过message事件进行接收,回调的参数分别对应发送的参数,即第一个参数为消息,第二个参数为服务,我们就可以在子进程创建服务并对主进程的服务进行监听和操作(listen除了可以监听端口号也可以监听服务),便实现了集群,代码如下。
//文件:server.js constos=require("os");//os模块用于获取系统信息 consthttp=require("http"); constpath=require("path"); const{fork}=rquire("child_process"); //创建服务 constserver=createServer((res,req)=>{ res.end("hello"); }).listen(3000); //根据CPU个数创建子进程 os.cpus().forEach(()=>{ fork("child_server.js",{ cwd:path.join(__dirname); }).send("server",server); });
//文件:child_server.js consthttp=require("http"); //接收来自主进程发来的服务 process.on("message",(data,server)=>{ http.createServer((req,res)=>{ res.end(`child${process.pid}`); }).listen(server);//子进程共用主进程的服务 });
上面代码中由主进程处理的请求会返回hello,由子进程处理的请求会返回child加进程的pid组成的字符串。
2、使用cluster实现集群
cluster模块是NodeJS提供的用来实现集群的,他将child_process创建子进程的方法集成进去,实现方式要比使用ipc更简洁。
//文件:cluster.js constcluster=require("cluster"); consthttp=require("http"); constos=require("os"); //判断当前执行的进程是否为主进程,为主进程则创建子进程,否则用子进程监听服务 if(cluster.isMaster){ //创建子进程 os.cpus().forEach(()=>cluster.fork()); }else{ //创建并监听服务 http.createServer((req,res)=>{ res.end(`child${process.pid}`); }).listen(3000); }
上面代码既会执行if又会执行else,这看似很奇怪,但其实不是在同一次执行的,主进程执行时会通过cluster.fork创建子进程,当子进程被创建会将该文件再次执行,此时则会执行else中对服务的监听,还有另一种用法将主进程和子进程执行的代码拆分开,逻辑更清晰,用法如下。
//文件:cluster.js constcluster=require("cluster"); constpath=require("path"); constos=require("os"); //设置子进程读取文件的路径 cluster.setupMaster({ exec:path.join(__dirname,"cluster-server.js") }); //创建子进程 os.cpus().forEach(()=>cluster.fork());
//文件:cluster-server.js consthttp=require("http"); //创建并监听服务 http.createServer((req,res)=>{ res.end(`child${process.pid}`); }).listen(3000);
通过cluster.setupMaster设置子进程执行文件以后,就可以将主进程和子进程的逻辑拆分开,在实际的开发中这样的方式也是最常用的,耦合度低,可读性好,更符合开发的原则。
总结
本篇着重的介绍了NodeJS多进程的实现方式以及集群的使用,之所以在开头长篇大论的介绍spawn,是因为其他的所有跟多进程相关的方法包括fork、exec等,以及模块cluster都是基于spawn的封装,如果对spawn足够了解,其他的也不在话下,希望大家通过这篇可以在NodeJS多进程相关的开发中起到一个“路标”的作用。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持毛票票。