九、进程
1. 多进程架构
-
Node提供了child_process模块,并提供了child_process.fork()函数供我们复制进程。
-
下面来看看一个例子:
// worker.js const http = require('http'); http.createServer((req,res)=>{ res.writeHead(200,{ 'Content-Type' : 'text/plain' }) res.end('hello,world'); }).listen(Math.round((Math.random() + 1) * 1000),'127.0.0.1'); // master.js const fork = require('child_process').fork; const cpus = require('os').cpus(); // cpu核心数 for(let i=0;i<cpus.length;i++) { fork('./worker.js'); // 复制进程 }
-
当启动master.js时,这段代码将会根据当前机器的CPU核心数量复制出对应的Node进程。
-
这就是著名的主从模式(master-worker模式),树已典型的分布式架构中用于并行处理业务的模式,具有良好的伸缩性和稳定性。
-
主进程不负责具体的业务处理,而是负责调度和管理工作进程的,趋向稳定的。
-
工作进程负责具体的业务处理,因为业务的多样性,所以工作进程的稳定性需要开发者注意。
-
通过fork复制的进程是一个独立的进程,这个进程有着独立而全新的V8示例,需要至少30ms的启动时间和10m的内存。
-
Node的事件驱动解决了单线程上的并发问题,但是启动多个进程只是为了充分利用CPU资源。
1.1 创建子进程
-
child_process模块提供了四种创建子进程的方法:
- spawn():启动一个子进程来执行命令;
- exec():启动一个子进程来执行命令,与上一个的不同是多了一个回调参数,用于获取子进程的情况;
- execFile():启动一个子进程来执行可以执行的文件;
- fork():与spawn()相似,不同在于它创建子进程只需要指定要执行的JavaScript文件即可。
-
1与2、3不同,后两者可以指定timeout属性,当进程超过执行时间就会被杀死;
-
2和3又不同,前者的参数为命令,后者为执行文件。
-
const cp = require('child_process'); cp.spawn('node',['worker.js']); cp.exec('node worker.js',function(err,stdout,stderr) { // do sth }); cp.execFile('worker.js',function(err,stdout,stderr) { // do sth }); cp.fork('./worker.js');
-
4种方法的差异:
类型 回调异常 进程类型 执行类型 可设置超时 spawn no 任意 命令 no exec yes 任意 命令 yes execFile yes 任意 可执行文件 yes fork no Node JavaScript文件 no -
如果是JavaScript文件通过execFile运行,则需要在文件首行加上
#!/usr/bin/env node
-
事实上,后面三种方法都是spawn的延展。
1.2 进程间的通信
-
webWorker允许创建工作线程并在后台运行,让一些严重阻塞前端UI渲染的计算不再占用主线程。
-
一个简单的例子:
// index.js let worker = new Worker('worker.js'); worker.onmessage = function(e) { document.getElementById('result').innerHTML = e.data; } // worker.js let n = 1; search : while(true) { n += 1; for(let i = 2; i <= Math.sqrt(n);i+= 1) { if(n % i == 0) { continue search; } } postMessage(n); }
<!-- index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <div id="result"></div> <script src="./index.js"></script> </body> </html>
以上例子,主进程index与工作线程worker通过postMessage和onmessage进行通信。子进程在后台进行静默计算,主线程负责接收子进程计算后的数据,并将其渲染到页面上。
-
而进程向进程发送数据的方法是send(),线程和进程通过on()方法接收传递的数据。
// parent.js const cp = require('child_process'); let n = cp.fork(__dirname + '/sub.js'); n.on('message',function(m) { console.log('主进程收到数据:',m); }) n.send("hello,i am parent"); // sub.js process.on('message',function(m) { console.log('子进程收到数据:',m); }) process.send('hi, i am child') // 运行parent.js的结果 子进程收到数据: hello,i am parent 主进程收到数据: hi, i am child
1.3 进程间的通信原理
- 通过4种方法创建子进程后,主进程和子进程间回建立起一条IPC通道;
- IPC的全称为Inter-Process Communication,即进程间通信。进程间通信是为了让进程能够互相访问资源并进行协调工作。
- 实现进程通信的技术有很多,如命名管道,匿名管道,socket,信号量,共享内存,消息队列,Domain Socket。
- IPC属于管道技术,具体实现是由libuv提供的,在Windows下是通过命名管道实现的。
- 父进程在创建子进程的过程中,会先创建IPC通道并监听它,再创建真正的子进程。并通过环境变量(NODE_CHANNEL_FD)来告诉子进程这个IPC通道的文件描述符,子进程通过文件描述符去连接这个IPC通道,从而完成与父进程之间的连接。
- IPC通道类似于Socket,属于双向通信,但是不需要经过网络层,而是直接在内存中完成通信,十分高效。
- 注意:只有Node子进程才能连接IPC通道,其他类型的子进程无法实现进程间的通信。除非其他进程也按照约定去连接IPC
1.4 句柄传递
-
在Node中,一个端口只能由一个进程监听,新的进程无法监听同一个端口。
-
因此,要解决多个进程监听,通常的做法是主进程监听主端口(80),接收网络请求后,再将这些请求分别代理到不同端口的进程上。
-
可以对代理进程进行负载均衡,让每个进程能够较为均衡地执行任务。
-
但是,使用代理有个缺点。由于每个进程没接收到一个连接,需要用掉一个文件描述符,因此代理方案中客户端连接到代理进程,代理进程连接主进程的过程中需要使用两个文件描述符,而操作系统的文件描述符是有限的,代理方案浪费了一倍的文件描述符影响力系统的扩展能力。
-
为了解决上面的问题,Node引入了进程间发送句柄的功能。
-
句柄就是一种可以标识资源的引用,它内部包含了指向对象的文件描述符。句柄可以用来标识一个服务器的socket对象,一个客户端的socket对象,一个UDP套接字,一个管道等。
-
发送句柄,意味着我们可以取消代理方案。主进程接收到socket请求后,这个socket直接发送给工作进程,让工作进程直接连接socket,而无需再让主进程于子进程建立连接来转发数据。
// master.js const child = require('child_process').fork('child.js'); let server = require('net').createServer(); server.on('connection',function(socket){ socket.end('这是主线程发出的响应'); }) server.listen(3000,function() { child.send('server',server); }) // child.js process.on('message',function(m,server) { if(m === 'server') { server.on('connection',function(socket) { socket.end('这是子进程处理的响应'); }) } }) // 测试结果 // 启动master.js,访问http://localhost:3000后,服务器返回: // 这是子进程处理的响应
-
在测试中,有时会是主进程处理响应,但大多数情况下都是子进程响应。
-
接下来测试HTTP层的情况、现在我们要做的是将HTTP句柄发送给子进程处理后,主进程关闭服务器的监听,让子进程来监听。
// master.js const child1 = require('child_process').fork('child.js'); const child2 = require('child_process').fork('child.js'); const server = require('net').createServer(); server.listen(3000,function() { child1.send('server',server); child2.send('server',server); // 关闭主进程监听 server.close(); }) // child.js let http = require('http'); let server = http.createServer(function(req,res) { res.writeHead(200,{ 'Content-Type' : 'text/plain' }) res.end('这是子进程处理的响应,pid='+ process.pid) }); process.on('message',function(m,tcp) { if(m === 'server') { tcp.on('connection',function(socket) { server.emit('connection',socket) }) } }) // 测试结果 // 子程序成功响应
-
以上代码中,主进程将连接传递给子进程后随即关闭自己的连接;子进程接收到TCP连接后,触发HTTP响应。
1.4.1 句柄的发送与还原
-
目前,send方法中可发送的句柄对象包含以下几种:
net.Socket。TCP套接字;
net.Server。TCP服务器,任意建立在TCP服务上的应用层都可以享受它带来的服务;
net.Native。C++层面的TCP套接字或者IPC管道。
dgram.Socket。UDP套接字。
dgram.Native。C++层面的UDP套接字。
-
send方法将消息发送到IPC管道前,会将消息组装成两个对象, 一个是文件描述符handle,一个是message。
-
message的格式如下:
{ cmd : 'NODE_HANDLE', type : 'net.Server', msg : message }
-
发送到管道的实际上是我们的文件描述符,文件描述符是一个整数,这个message对象也会通过Stringify方法将其转化为字符串,再送入管道中。
-
连接了IPC管道的子进程接收到message对象字符串后,将其转化为对象,才触发message事件。
-
Node进程间传递的只是字符串,而不是真正地传递对象。
-
message.cmd如果以NODE_开头,则将响应一个内部事件。像上面的例子,如果是’NODE_HANDLE’,它将取出type值,然后和文件描述符组合后还原出一个对象。
1.4.2 端口共同监听
- Node底层对每个端口监听都设置了
SO_REUSEADDR
,意为不同进程之间可以就相同的网卡和端口进行监听,这个服务器端的套接字可以被不同的进程复用。 - 由于独立启动的进程之间互相不知道文件描述符,因此监听相同的端口会失败。但对于句柄发送的文件描述符,他们都是一样的,因此可以监听同一个端口。
- 多个应用监听相同端口,只有一个可以成功监听,这些进程服务是抢断式(内卷)的。
2. 集群稳定
2.1 进程事件
-
除了message事件,Node还有以下进程事件:
error:当子进程无法被复制创建、无法被杀死、无法发送消息时会触发这个事件;
exit:当子进程退出时触发该事件。正常退出,第一个参数为退出码,否则为null;若被杀死,则会在第二个参数返回杀死进程的信号。
close:在子进程的标准输入输出流中止时触发,参数与exit相同。
disconnect:在父进程或者子进程调用disconnect方法时触发,将关闭IPC通道。
-
以上是父进程监听子进程状态的事件。除了send方法外,他还有kill方法用来给子进程推送信号,但是并不能将连接IPC的子进程真正杀死,只是给其推送了个信号
SIGTERM
,表示终止软件信号,进程收到时应该退出。process.kill(pid,[signal]); // 当前进程child.kill([signal]); // 子进程
2.2 自动重启
-
我们可以在主进程中对子进程进行一些管理机制,比如重新启动一个新的进程来继续服务。
// master.jsconst fork = require('child_process').fork;const cpus = require('os').cpus();const server = require('net').createServer();server.listen(1337);let workers = {};let createWorker = function() { var worker = fork(__dirname + '/worker.js'); // 退出时重启进程 worker.on('exit',function() { console.log('worker ' + worker.pid + ' exited'); delete workers[worker.pid]; createWorker(); }) // 句柄转发 worker.send('server',server); workers[worker.pid] = worker; console.log('create worker ' + worker.pid);}for (let i=0;i<cpus.length;i++) { createWorker();}// 进程自己退出时,关闭所有工作进程process.on('exit',function () { for (let pid in workers) { workers[pid].kill(); }})// worker.jsconst http = require('http');let server = http.createServer(function(req,res) { res.writeHead(200,{ 'Content-Type' : 'text/plain' }) res.end('handled by worker ' + process.pid + '\n');})var worker;process.on('message' , function(m,tcp) { if(m === 'server') { worker = tcp; worker.on('connection',function(socket) { server.emit('connection' , socket); }) }})process.on('uncaughtException',function() { // 停止接收连接 worker.close(function() { process.exit(1); })})// 测试结果create worker 4016create worker 3808create worker 1808create worker 14352create worker 13176create worker 10848create worker 13856create worker 3204$ kill 3204worker 3204 exitedcreate worker 3460
-
在测试中,我们将3204进程杀死,子进程触发’uncaughtException’错误,于是停止接收连接;主进程监听到exit事件,将对应的子进程删除后,重新启动一个新的进程来代替,以保证整个集群一直有进程在为用户服务。
2.2.1 自杀信号
-
在极端情况下,如果所有的工作进程同时被杀死,关闭了接收请求的通道,在重新启动新的线程过程中,所有新的请求都会被拒之门外,因此会丢掉打大部分请求;
-
因此需要对上面的代码进行修改,不能等到完全退出后再重新启动线程。我们需要在退出的过程中添加一个自杀信号。
-
当主进程接收到自杀信号的时候,就立即创建新的进程。
// worker.jsprocess.on('uncaughtException',function() { process.send({ act : 'suicide' }) worker.close(function () { process.exit(1); })})// master.jslet createWorker = function() { var worker = fork(__dirname + '/worker.js'); // 接收到自杀信号,立即重启一个新的进程 worker.on('message',function(message) { if(message.act === 'suicide') { createWorker(); } }) worker.on('exit',function() { console.log('worker ' + worker.pid + ' exited'); delete workers[worker.pid]; }) // 句柄转发 worker.send('server',server); workers[worker.pid] = worker; console.log('create worker ' + worker.pid);}
-
这里存在的问题是我们的连接可能是长连接,不是HTTP短链接,等待长连接断开可能需要比较久的时间。因此,需要对已有的连接设定一个超时时间,在限定时间内强制退出。
-
同时,如果出现未捕获的错误时,将其记录在日志中,方便追踪代码异常。
// worker.jsprocess.on('uncaughtException',function(err) { logger.log(err); process.send({ act : 'suicide' }) worker.close(function () { process.exit(1); }) // 5s后退出进程 setTimeout(function () { process.exit(1); },5000)})
2.2.2 限量重启
-
还有一个问题,进程不能无限制启动。如果一个错误是一直发生的而不被解决,那么一旦启动一个新的线程,就会再次捕获这个错误并重新启动,如此下去会导致工作进程频繁自启。
-
因此,我们需要设定一个指标作为重启次数限制,一旦超过便触发giveup事件,告知主进程放弃重启工作进程。
var limit = 10;// 时间单位let duration = 60000;let restart = [];let isTooFrequent = function() { // 记录重启时间 let time = Date.now(); let length = restart.push(time); if(length > limit) { // 取出后10个时间 restart = restart.slice(limit * (-1)); } return restart.length >= limit && restart[restart.length - 1] - restart[0] < duration;}// .......let createWorker = function() { var worker = fork(__dirname + '/worker.js'); // 检查是否过于频繁 if(isTooFrequent()) { process.emit('giveup',length,duration); return ; } // 退出时重启进程 worker.on('message',function(message) { if(message.act === 'suicide') { createWorker(); } }) worker.on('exit',function() { console.log('worker ' + worker.pid + ' exited'); delete workers[worker.pid]; }) // 句柄转发 worker.send('server',server); workers[worker.pid] = worker; console.log('create worker ' + worker.pid);}
-
giveup事件比未捕获异常更加严重,因为他会导致整个集群没有任何进程可以服务了。
-
同样,为了健壮性,我们需要在giveup事件里添加上日志,让监控系统监视到这个严重的错误。
2.3 负载均衡
-
Node默认的机制是采用系统的抢占式策略,各个进程根据自己的繁忙度来进行抢占。
-
对于Node而言,区分繁忙度的由CPU和IO两部分构成,影响抢占的是CPU。
-
某些业务,可能在IO比较繁忙,CPU空闲的情况,这时可能会造成某个进程将所有的请求都包揽了,形成负载不均衡的情况。
-
为此,Node提供了一种叫做“Round-Robin”的策略,即轮叫调度。工作方式是由主进程接收连接,将其依次分发给工作线程(取模分配),保证公平分配。
-
但是这种策略也是通过代理服务器来实现的,所以会导致服务器上消耗的文件描述符是平常的两倍。
-
启用方式:
-
在cluster模块中启动:
// 启动cluster.schedulingPolicy = cluster.SCHED_RR;// 关闭cluster.schedulingPolicy = cluster.SCHED_NONE;
-
在环境变量中启动:
export NODE_CLUSTER_SCHED_POLICY = rr;export NODE_CLUSTER_SCHED_POLICY = none;
-
2.4 状态共享(第三方数据存储)
- 将数据存放到数据库、磁盘文件、缓存服务中,所有工作进程启动时读取并存进内存中。
- 但是,如果有数据更新,就必须有一种机制通知所有子进程重新获取数据,使得内部状态获得更新。
2.4.1 定时轮询
- 各个进程定时向第三方数据存储轮询。
- 如果轮询过密,子进程一多,就会形成并发处理,数据没发生改变,这些轮询就会没有意义,白白花费开销;
- 如果轮询时间太长,数据发生改变时,无法及时更新。
2.4.2 主动通知
- 当数据发生改变时主动通知子进程更新数据。
- 这种依然需要依靠轮询来实现,但是轮询的进程数量会大大减少,我们只需要开启一个工作进程,称为通知进程。这个进程只负责轮询和通知。
- 这种机制在跨多台服务器就会失效,故可以考虑TCP和UDP的方案。
- 通知进程启动时,获取第一次数据后,还将进程信息注册到通知服务中,一旦轮询发现数据更新,则根据注册信息,将更新后的数据发送给工作进程。
3. Cluster模块
-
cluster模块用于解决多核CPU的利用率问题,用以处理进程的健壮性问题。
-
第一节提到的创建子进程,利用cluster可以简单实现:
// cluster.jsconst cluster = require("cluster");const cpus = require("os").cpus();// 推荐cluster.setupMaster({ exec : 'worker.js'})for (let i = 0; i < cpus.length; i++) { cluster.fork();}
这种方式创建的子进程集群与前文一致,不过官方文档还提供了另外一种方式:
const cluster = require("cluster");const http = require("http");const cpus = require("os").cpus();if (cluster.isMaster) { // 如果是主线程 for (let i = 0; i < cpus.length; i++) { cluster.fork(); } cluster.on("exit", function (worker, code, signal) { console.log("worker " + worker.process.pid + " died"); });} else { // 不是主线程,则直接搭建TCP连接共享 http .createServer(function (req, res) { res.writeHead(200); res.end("hello!"); }) .listen(3000);}
这种方式在进程中判断是否为主进程,取决于环节变量中是否有
NODE_UNIQUE_ID
。这个是用来识别子进程的。
3.1 工作原理
- cluster是child_process和net模块的综合运用。
- cluster启动时,会在内部启动TCP服务器,在调用fork()创建子进程时,cluster会将这个TCP服务器端的socket的文件描述符发送给工作进程。
- 如果进程通过fork来复制的,那么他的环境变量里就会存在一个
NODE_UNIQUE_ID
。若工作进程存在listen()监听网络端口时,他就会拿到这个文件描述符,通过SO_REUSEADDR端口复用,实现多个子进程共享端口。 - 而普通的进程复制却没有这样的文件描述符传递和共享。
- 在cluster模块中,一个主进程只能控制一组工作进程。
- 但是自己通过child_process来操作可以更加灵活控制工作进程,甚至控制多组工作进程。
- 原因是在自行操作子进程时可以隐式地构造多个TCP服务器,从而可以共享多个socket文件描述符。