进程
如今CPU基本均是多核的,真正的服务器往往还有多个CPU。一个Node进程只能利用一个核,这将抛出Node实际应用的第一个问题:如何充分利用多核CPU服务器?
由于Node执行在单线程上,一旦单线程上抛出的异常没有被捕获,将会引起整个进程的崩溃。这给Node的实际应用抛出了第二个问题:如何保证进程的健壮性和稳定性?
# 01. 服务模型的变迁
01.同步 -> 02.复制进程 -> 03.多线程 -> 04.事件驱动
# 02. 多进程架构
面对单进程单线程对多核使用不足的问题,前人的经验是启动多进程即可。理想状态下每个进程各自利用一个CPU,以此实现多核CPU的利用。Node提供了child_process
模块,并且也提供了child_process.fork()
函数供我们实现进程的复制。
// work.js
var http = require('http');
http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
}).listen(Math.round((1 + Math.random()) * 1000), '127.0.0.1');
// master.js
var fork = require('child_process').fork;
var cpus = require('os').cpus();
for (var i = 0; i < cpus.length; i++) {
fork('./worker.js');
}
ps aux | grep worker.js
查看到进程的数量,如下所示:
wsh 7597 0.0 0.0 408121728 1392 s005 S+ 5:19下午 0:00.00 grep work.js
wsh 7546 0.0 1.0 8939348 84048 s004 S+ 5:16下午 0:00.08 /usr/local/bin/node ./work.js
wsh 7545 0.0 1.0 9349204 84928 s004 S+ 5:16下午 0:00.09 /usr/local/bin/node ./work.js
wsh 7544 0.0 1.0 8964148 84672 s004 S+ 5:16下午 0:00.08 /usr/local/bin/node ./work.js
wsh 7543 0.0 1.0 9095252 82704 s004 S+ 5:16下午 0:00.09 /usr/local/bin/node ./work.js
wsh 7542 0.0 1.0 8963924 82880 s004 S+ 5:16下午 0:00.09 /usr/local/bin/node ./work.js
wsh 7541 0.0 1.0 8963924 85488 s004 S+ 5:16下午 0:00.09 /usr/local/bin/node ./work.js
wsh 7540 0.0 1.0 9095252 83472 s004 S+ 5:16下午 0:00.09 /usr/local/bin/node ./work.js
wsh 7539 0.0 1.0 8963924 83856 s004 S+ 5:16下午 0:00.10 /usr/local/bin/node ./work.js
wsh 7515 0.0 1.0 8964180 84464 s007 S+ 5:16下午 0:00.12 node work.js
Master-Worker模式又称主从模式。图中的进程分为两种:主进程和工作进程。 这是典型的分布式架构中用于并行处理业务的模式,具备较好的可伸缩性和稳定性。
- 主进程不负责具体的业务处理,而是负责调度或管理工作进程,它是趋向于稳定的。
- 工作进程负责具体的业务处理,因为业务的多种多样,甚至一项业务由多人开发完成,所以工作进程的稳定性值得开发者关注。
Master-Worker模式:
提示
通过fork()复制的进程都是一个独立的进程,这个进程中有着独立而全新的V8实例。它需要 至少30毫秒的启动时间和至少10 MB的内存
# 创建子进程
child_process
模块给予Node可以随意创建子进程(child_process)的能力。它提供了4个方法用于创建子进程。
- spawn():启动一个子进程来执行命令。
- exec():启动一个子进程来执行命令,与spawn()不同的是其接口不同,它有一个回调函数获知子进程的状况。
- execFile():启动一个子进程来执行可执行文件。
- fork():与spawn()类似,不同点在于它创建Node的子进程只需指定要执行的JavaScript文件模块即可。
spawn()与exec()、execFile()不同的是,后两者创建时可以指定timeout属性设置超时时间,一旦创建的进程运行超过设定的时间将会被kill。 exec()与execFile()不同的是,exec()适合执行已有的命令,execFile()适合执行文件。
var cp = require('child_process');
cp.spawn('node', ['worker.js']);
cp.exec('node worker.js', function (err, stdout, stderr) {
// some code
});
cp.execFile('worker.js', function (err, stdout, stderr) {
// some code
});
cp.fork('./worker.js');
4种方法的差别:
这里的可执行文件是指可以直接执行的文件,如果是JavaScript文件通过execFile()运行,它的首行内容必须添加如下代码:
#!/usr/bin/env node
# 进程间通信
在前端浏览器中,JavaScript主线程与UI渲染共用同一个线程。执行JavaScript的时候UI渲染是停滞的,渲染UI时,JavaScript是停滞的,两者互相阻塞。长时间执行JavaScript将会造成UI停顿不响应。为了解决这个问题,HTML5提出了WebWorker API。WebWorker允许创建工作线程并在后台运行,使得一些阻塞较为严重的计算不影响主线程上的UI渲染。
WebWorker API: 主线程与工作线程之间通过onmessage()和postMessage()进行通信,子进程对象则由send()方法实现主进程向子进程发送数据,message事件实现收听子进程发来的数据
Node中
// parent.js
var cp = require('child_process');
var n = cp.fork(__dirname + '/sub.js');
n.on('message', function (m) {
console.log('PARENT got message:', m);
});
n.send({hello: 'world'});
// sub.js
process.on('message', function (m) {
console.log('CHILD got message:', m);
});
process.send({foo: 'bar'});
// node parent.js
输出结果:
PARENT got message: { foo: 'bar' }
CHILD got message: { hello: 'world' }
通过fork()或者其他API,创建子进程之后,为了实现父子进程之间的通信,父进程与子进程之间将会创建IPC通道。通过IPC通道,父子进程之间才能通过message和send()传递消息。
# 进程间通信原理
IPC的全称是Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同的进程能够互相访问资源并进行协调工作。 Node中实现IPC通道的是管道(pipe)技术
父进程在实际创建子进程之前,会创建IPC通道并监听它,然后才真正创建出子进程,并通过环境变量(NODE_CHANNEL_FD)告诉子进程这个IPC通道的文件描述符。子进程在启动的过程中, 根据文件描述符去连接这个已存在的IPC通道,从而完成父子进程之间的连接。
提示
只有启动的子进程是Node进程时,子进程才会根据环境变量去连接IPC通道,对于其他类型 的子进程则无法实现进程间通信,除非其他进程也按约定去连接这个已经创建好的IPC通道。
# 句柄传递
多个进程监听同一个端口的,提示端口被占用解决方案: 让每个进程监听不同的端口,其中主进程监听主端口(如80),主进程对外接收所有的网络请求,再将这些请求分别代理 到不同的端口的进程上。
主进程接收、分配网络请求的示意图:
优点: 通过代理,可以避免端口不能重复监听的问题,甚至可以在代理进程上做适当的负载均衡,使得每个子进程可以较为均衡地执行任务。
缺点: 由于进程每接收到一个连接,将会用掉一个文件描述符,因此代理方案中客户端连接到代理进程,代理进程连接到工作进程的过程需要用掉两个文件描述符。操作系统的文件描述符是有限的,代理方案浪费掉一倍数量的文件描述符的做法影响了系统的扩展能力。
# 句柄概念
提示
句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符。比如句柄可以用来标识一个服务器端socket对象、一个客户端socket对象、一个UDP套接字、一个管道等。
为了解决上述这样的问题,Node在版本v0.5.9引入了进程间发送句柄的功能send()方法除了能通过IPC(进程间通信)发送数据外,还能发送句柄,第二个可选参数就是句柄,如下所示: child.send(message, [sendHandle])
// parent.js
var cp = require('child_process');
var child1 = cp.fork('child.js');
var child2 = cp.fork('child.js');
// Open up the server object and send the handle
var server = require('net').createServer();
server.listen(1337, function () {
child1.send('server', server);
child2.send('server', server);
// 关掉
server.close();
});
// child.js
var http = require('http');
var server = http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('handled by child, pid is ' + process.pid + '\n');
});
process.on('message', function (m, tcp) {
if (m === 'server') {
tcp.on('connection', function (socket) {
server.emit('connection', socket);
});
}
});
主进程将请求发送给工作进程:
主进程发送完句柄并关闭监听(server.close())之后,成为了如图所示的结构
目前子进程对象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
}
发送到IPC管道中的实际上是我们要发送的句柄文件描述符
,文件描述符实际上是一个整数值。这个message对象在写入到IPC管道时也会通过JSON.stringify()进行序列化。所以最终发送到IPC通道中的信息都是字符串,send()方法能发送消息和句柄并不意味着它能发送任意对象。
连接了IPC通道的子进程可以读取到父进程发来的消息,将字符串通过JSON.parse()解析还原为对象后,才触发message事件将消息体传递给应用层使用。在这个过程中,消息对象还要被
进行过滤处理,message.cmd的值如果以NODE_为前缀
,它将响应一个内部事件internalMessage
。如果message.cmd值为NODE_HANDLE,它将取出message.type值和得到的文件描述符一起还原出一个对应的对象。
句柄的发送与还原示意图:
- 端口共同监听
为何通过发送句柄后,多个进程可以监听到相同的端口而不引起EADDRINUSE异常?
我们独立启动的进程中,TCP服务器端socket套接字的文件描述符
并不相同,导致监听到相同的端口时会抛出异常。
Node底层对每个端口监听都设置了SO_REUSEADDR
选项,这个选项的涵义是不同进程可以就相
同的网卡和端口进行监听,这个服务器端套接字可以被不同的进程复用,如下所示:
setsockopt(tcp->io_watcher.fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on))
由于独立启动的进程互相之间并不知道文件描述符,所以监听相同端口时就会失败。但对于send()发送的句柄还原出来的服务而言,它们的文件描述符是相同的,所以监听相同端口不会引 起异常。
多个应用监听相同端口时,文件描述符同一时间只能被某个进程所用。换言之就是网络请求向服务器端发送时,只有一个幸运的进程能够抢到连接,也就是说只有它能为这个请求进行服务。 这些进程服务是抢占式的。
# 03. 集群稳定之路
每个工作进程依然是在单线程上执行的,它的稳定性还不能得到完全的保障。我们需要建立起一个健全的机制来保障Node应用的健壮性。
# 进程事件
子进程除了send()方法和message事件外,还有如下这些事件。
- error:当子进程无法被复制创建、无法被杀死、无法发送消息时会触发该事件。
- exit:子进程退出时触发该事件,子进程如果是正常退出,这个事件的第一个参数为退出码,否则为null。如果进程是通过kill()方法被杀死的,会得到第二个参数,它表示杀死进程时的信号。
- close:在子进程的标准输入输出流中止时触发该事件,参数与exit相同。
- disconnect:在父进程或子进程中调用disconnect()方法时触发该事件,在调用该方法时将关闭监听IPC通道。
通过kill()方法给子进程发送消息
// 子进程
child.kill([signal]);
// 当前进程
process.kill(pid, [signal]);
信号列表:kill -l
HUP INT QUIT ILL TRAP ABRT EMT FPE KILL BUS SEGV SYS PIPE ALRM TERM URG STOP TSTP CONT CHLD TTIN TTOU IO XCPU XFSZ VTALRM PROF WINCH INFO USR1 USR2
Node提供了这些信号对应的信号事件,每个进程都可以监听这些信号事件。这些信号事件是用来通知进程的,每个信号事件有不同的含义,进程在收到响应信号时,应当做出约定的行为, 如SIGTERM是软件终止信号,进程收到该信号时应当退出
process.on('SIGTERM', function() {
console.log('Got a SIGTERM, exiting...');
process.exit(1);
});
console.log('server running with PID:', process.pid);
process.kill(process.pid, 'SIGTERM');
# 自动启动
重新启动一个工作进程来继续服务:
主进程加入子进程管理机制的示意图:
// master.js
var fork = require('child_process').fork;
var cpus = require('os').cpus();
var server = require('net').createServer();
server.listen(1337);
var workers = {};
var 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. pid: ' + worker.pid);
};
for (var i = 0; i < cpus.length; i++) {
createWorker();
}
// 进程自己退出时,让所有工作进程退出
process.on('exit', function () {
for (var pid in workers) {
workers[pid].kill();
}
});
输出:
Create worker. pid: 15199
Create worker. pid: 15200
Create worker. pid: 15201
Create worker. pid: 15202
Create worker. pid: 15203
Create worker. pid: 15204
Create worker. pid: 15205
Create worker. pid: 15206
kill 15199
: 15199进程退出后,自动启动了一个新的工作进程30518,总体进程数量并没有发生改变
Create worker. pid: 15199
Create worker. pid: 15200
Create worker. pid: 15201
Create worker. pid: 15202
Create worker. pid: 15203
Create worker. pid: 15204
Create worker. pid: 15205
Create worker. pid: 15206
Worker 15199 exited.
Create worker. pid: 15223
# 自杀信号
进程的自杀和重启:
主进程将重启工作进程的任务,从exit事件的处理函数中转移到message事件的处理函数中,
var 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. pid: ' + worker.pid);
};
# 限量重启
了完成限量重启的统计,我们引入一个队列来做标记,在每次重启工作进程之间进行打点并判断重启是否太过频繁
// 重启次数
var limit = 10;
// 时间单位
var during = 60000;
var restart = [];
var isTooFrequently = function () {
// 记录重启时间
var time = Date.now();
var length = restart.push(time);
if (length > limit) {
// 取出最后10个记录
restart = restart.slice(limit * -1);
}
// 最后一次重启到前10次重启之间的时间间隔
return restart.length >= limit && restart[restart.length - 1] - restart[0] < during;
};
var workers = {};
var createWorker = function () {
// 检查是否太过频繁
if (isTooFrequently()) {
// 触发giveup事件后,不再重启
process.emit('giveup', length, during);
return;
}
var worker = fork(__dirname + '/worker.js');
worker.on('exit', function () {
console.log('Worker ' + worker.pid + ' exited.');
delete workers[worker.pid];
});
// 重新启动新的进程
worker.on('message', function (message) {
if (message.act === 'suicide') {
createWorker();
}
});
// 句柄转发
worker.send('server', server);
workers[worker.pid] = worker;
console.log('Create worker. pid: ' + worker.pid);
};
# 负载均衡
在多进程之间监听相同的端口,使得用户请求能够分散到多个进程上进行处理,这带来的好处是可以将CPU资源都调用起来。保证多个处理单元工作量公平的策略叫负载均衡。
Node默认提供的机制是采用操作系统的抢占式策略。所谓的抢占式就是在一堆工作进程中,闲着的进程对到来的请求进行争抢,谁抢到谁服务。
Node在 v0.11中提供了一种新的策略使得负载均衡更合理,这种新的策略叫Round-Robin,又叫轮叫调度
。轮叫调度的工作方式是由主进程接受连接,将其依次分发给工作
进程。分发的策略是在N个工作进程中,每次选择第i = (i + 1) mod n个进程来发送连接。在cluster模块中启用它的方式如下:
// 启用Round-Robin
cluster.schedulingPolicy = cluster.SCHED_RR
// 不启用Round-Robin
cluster.schedulingPolicy = cluster.SCHED_NONE
# 状态共享
- 第三方数据存储:
解决数据共享最直接、简单的方式就是通过第三方来进行数据存储,比如将数据存放到数据库、磁盘文件、缓存服务(如Redis)中,所有工作进程启动时将其读取进内存中。但这种方式 存在的问题是如果数据发生改变,还需要一种机制通知到各个子进程,使得它们的内部状态也得到更新。
实现状态同步的机制有两种:
- 各个子进程去向第三方进行定时轮询
- 主动通知: 当数据发生更新时,主动通知子进程。当然,即使是主动通知,也需要一种机制来及时获取数据的改变。这个过程仍然不能脱离轮询,但我们可以减少轮询的进程数量, 我们将这种用来发送通知和查询状态是否更改的进程叫做通知进程。为了不混合业务逻辑,可以将这个进程设计为只进行轮询和通知,不处理任何业务逻辑
# 04. Cluster 模块
Node.js 进程集群可用于运行多个 Node.js 实例,这些实例可以在其应用程序线程之间分配工作负载。 集群模块可以轻松创建共享服务器端口的子进程。
cluster集成两个方面:
- 集成了child_process.fork方法创建node子进程的方式;
- 继承了很多多核cpu创建子进程后,自动控制负载均衡的方式;
创建Node进程集群
// cluster.js
var cluster = require('cluster');
cluster.setupMaster({
exec: "worker.js"
});
var cpus = require('os').cpus();
for (var i = 0; i < cpus.length; i++) {
cluster.fork();
}
# Cluster 工作原理
cluster模块就是child_process和net模块的组合应用。
cluster启动时,会在内部启动TCP服务器,在cluster.fork()子进程时,将这个TCP服务器端socket的文件描述符发送给工作进程。如果进程是通过cluster.fork()复制出来的,那么
它的环境变量里就存在NODE_UNIQUE_ID
,如果工作进程中存在listen()侦听网络端口的调用,它将拿到该文件描述符,通过SO_REUSEADDR端口重用,从而实现多个子进程共享端口。对于普通方式启动的进程,则不存在文件描述符传递共享等事情。
# Cluster 事件
对于健壮性处理,cluster模块也暴露了相当多的事件。
- fork:复制一个工作进程后触发该事件。
- online:复制好一个工作进程后,工作进程主动发送一条online消息给主进程,主进程收 到消息后,触发该事件。
- listening:工作进程中调用listen()(共享了服务器端Socket)后,发送一条listening 消息给主进程,主进程收到消息后,触发该事件。
- disconnect:主进程和工作进程之间IPC通道断开后会触发该事件。
- exit:有工作进程退出时触发该事件。
- setup:cluster.setupMaster()执行后触发该事件。 这些事件大多跟child_process模块的事件相关,在进程间消息传递的基础上完成的封装。 这些事件对于增强应用的健壮性已经足够了。
# Worker 类
Worker对象包含了关于工作进程的所有的公共的信息和方法。
- 主进程中,使用cluster.workers获取
- 工作进程中,使用cluster.worker获取
# disconnect事件
断开连接
# error事件
工作进程中,可以使用process.on(‘error’)
# exit事件
const worker = cluster.fork();
worker.on('exit', (code, signal) => {
if (signal) {
console.log(`工作进程已被信号 ${signal} 杀死`);
} else if (code !== 0) {
console.log(`工作进程退出,退出码: ${code}`);
} else {
console.log('工作进程成功退出');
}
});
# listening事件
cluster.fork().on('listening', (address) => {
// 工作进程正在监听。
});
# message事件
在工作进程内,也可以使用 process.on("message")
# online事件
cluster.fork().on('online', () => {
// 工作进程已上线。
});
# worker.disconnect()
在一个工作进程内,调用此方法会关闭所有的server
,并等待这些 server 的 close
事件执行,然后关闭 IPC 管道。
在主进程内,会给工作进程发送一个内部消息,导致工作进程自身调用 .disconnect()
# worker.exitedAfterDisconnect
- 工作进程由于
.kill()
或.disconnect()
而退出,则此属性为 true。 - 工作进程以任何其他方式退出,则为 false。
- 工作进程尚未退出,则为 undefined。
cluster.on('exit', (worker, code, signal) => {
if (worker.exitedAfterDisconnect === true) {
console.log('这是自动退出,无需担心');
}
});
// 杀死工作进程。
worker.kill();
# worker.kill([signal=‘SIGTERM’])
- 杀死工作进程。
- 主进程,通过断开与worker.process的连接来实现,一旦断开连接,通过signal杀死工作进程;
- 工作进程,通过断开IPC管道来实现,然后以代码0退出进程;
# worker.process
- 所有的工作都通过child_process.fork()创建,返回的对象被存储为.process。工作进程中,process属于全局对象。
- 当process发生disconnect事件,且.exitedAfterDisconnect的值不是true时,工作进程会调用process.exit(0)来防止连接意外断开。
# worker.send(message[, sendHandle[, options]][, callback])
- message
- sendHandle(句柄)
- options options 参数(如果存在)是用于参数化某些类型句柄的发送的对象。 options 支持以下属性:
- keepOpen 当传入 net.Socket 实例时可以使用的值。 当为 true 时,套接字在发送过程中保持打开状态。 默认值: false。
- callback
- 返回: boolean
发送消息给主进程或工作进程。
if (cluster.isMaster) {
const worker = cluster.fork();
worker.send('hello');
} else if (cluster.isWorker) {
process.on('message', (msg) => {
process.send(msg);
});
}
# disconnect事件
在工作进程 IPC 通道断开连接后触发。 当工作进程正常退出、被杀死,手动断开连接(例如使用 worker.disconnect())时,可能会发生这种情况。
disconnect
和 exit
事件之间可能存在延迟。 这些事件可用于检测进程是否陷入清理或是否存在长期连接。
cluster.on('disconnect', (worker) => {
console.log(`The worker #${worker.id} has disconnected`);
});
# exit事件
- worker
- code 如果其正常退出,则为退出码。
- signal 造成进程被终止的信号的名称(例如 'SIGHUP')。
当任何工作进程死亡时,则集群模块将触发 'exit' 事件。 这可用于通过再次调用 .fork() 来重新启动工作进程。
cluster.on('exit', (worker, code, signal) => {
console.log('worker %d died (%s). restarting...',
worker.process.pid, signal || code);
cluster.fork();
});
# cluster.isMaster/cluster.isPrimary
判断进程是否是主进程,由 process.env.NODE_UNIQUE_ID 决定的。 如果 process.env.NODE_UNIQUE_ID 未定义,则 为 true
- node < v16.0.0 cluster.isMaster
- node >= v16.0.0 cluster.isPrimary