Node基于V8引擎构建,与浏览器类似,我们的JS将会运行在单个进程的单个线程上,他带来的好处是:程序状态是单一的,没有锁和线程同步问题,没有上下文切换CPU使用率高。但是还是有别的问题:CPU的多核无法利用,单线程上有异常并没有被捕获的情况下,服务器就无法继续提供服务了。
多进程架构
Node提供了child_process模块,这个模块可以做到打开新的进程来执行任务。最理想的状态应该是每个进程各自利用一个CPU:
var fork = require('child_process').fork;
var cpus = require('os').cpus();
for (var i = 0; i < cpus.length; i++) {
fork('./worker.js');
}
worker.js中就可以执行:
var http = require('http');
var port = Math.round((1 + Math.random()) * 1000);
http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(port+"");
}).listen(port, '127.0.0.1');
console.log("PORT:"+port);
这个就是主从模式,主进程用来调度各个工作进程,不负责处理具体的业务逻辑,这个进程应该是很稳定的。工作进程负责具体的业务处理。
我们上面用到的fork方法创建的进程是独立的,在这个进程中有一个全新的V8实例,它需要额外的启动时间和内存。fork的代价是昂贵的,但是如果是为了充分调动CPU而不是处理单个的请求,就是值得的。
创建子进程
child_process模块提供了4个方法来创建子进程。spawn()、exec()、execFile()。
- spawn:启动一个子进程来执行命令
- exec:启动一个子进程来执行命令 ,有一个回调函数来获得子进程的状态
- execFile:启动一个进程来执行可执行文件
- fork:这个是专门来执行JS的
var cp = require('child_process');
cp.spawn('node', ['./worker.js']);
cp.exec('node ./worker.js', function (err, stdout, stderr) {
console.log("exec:"+stdout);
console.log(err);
});
//使用execFile直接执行JS文件要在文件前加上#!/usr/bin/env node
cp.execFile('./worker.js', function (err, stdout, stderr) {
console.log("execFile:"+stdout);
});
cp.fork('./worker.js');
进程间通信
这个就像是WebWorker,使用事件来监听子进程或父进程发来的消息,使用send方法给对方发送消息:
主进程:
cp.fork('./worker.js');
var n = cp.fork('./sub.js');
n.on('message', function (m) {
console.log('PARENT got message:', m);
});
n.send({hello: 'world'});
子进程:
process.on('message', function (m) {
console.log('CHILD got message:', m);
});
process.send({foo: 'bar'});
句柄传递
我们先来看看使用句柄可以达到什么样的效果,假如我想有多个进程同时监听8080端口,同时处理这个端口的请求,那在每一个进程里监听8080是不行的,会报错。但是我们可以通过句柄将服务器对象传递到子进程里,这样每个子进程都可以处理这个端口的请求:
主进程:
var cp = require('child_process');
var child1 = cp.fork('./jubing-child.js');
var child2 = cp.fork('./jubing-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();
});
子进程:
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);
});
}
});
句柄的发送与还原
send并没有直接把服务器对象发送给子进程,他只是将对象类型,文件描述符等信息拼成字符串传给了子进程。send方法只能发送如下几种句柄类型:
- net.Socket:TCP套接字
- net.Server:TCP服务器
- net.Native:C++层面的TCP套接字
- dgram.Socket:UDP套接字
- dgram.Native:C++层面的UDP套接字
send将这几种句柄发送到子进程时,子进程会使用对象类型和文件描述符来还原出一个对应的对象,这个文件描述符会被新的对象和父进程中的对象同时监听,这就达到了同时监听的效果。但是文件描述符同一时间只能被某一个进程所用,所以只有一个线程可以抢到连接。
集群稳定之路
这里充分利用CPU资源已经做到了,但是还有一些细节要考虑:
- 性能问题
- 多个工作进程的存活状态管理
- 工作进程的平滑重启
- 配置或静态数据的动态重新载入
- 单个工作进程的稳定性
进程事件
- error:当子进程无法被复制创建,无法被杀死,无法发送消息时触发该事件
- exit:子进程退出时,正常退出时,这个事件第一个参数是退出码,通过kill()杀死时第二个参数为杀死进程时的信号
- close:子进程的标准输入输出流终止时触发
- disconnect:在父进程中调用disconnect()方法时触发,将关闭监听IPC通道
父进程除了可以使用send()外,还可以使用kill方法给子进程发送消息,kill方法并不能真正的杀死进程,它只是给子进程发送一个SIGTERM信号:
// 子进程
child.kill([signal]);
// 指定进程
process.kill(pid, [signal]);
每个进程都可以监听这些事件,并作出应做的反应:
process.on('SIGTERM', function() {
console.log('Got a SIGTERM, exiting...');
process.exit(1);
});
自动重启
有了这些事件,我们就可以创建一些需要的进程管理的机制了。比如有个进程挂了,我们自动重启他。
// 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 + '/auto_reboot-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();
}
});
这时,如果子进程出错退出或主动杀死一个子进程都会再创建一个新的子进程。
在子进程中如果出错也需要做一些处理:
process.on('uncaughtException', function () {
worker.close(function () {
process.exit(1);
});
});
停止接收新的连接,等待现有连接关闭后退出,告诉主进程以便主进程调度。
自杀信号
这里有个极端情况,如果所有的子进程都出错了,都停止接收新的连接且在等待当前连接关闭,那就没有进程服务用户了。我们应该把通知主进程的过程提前:
process.on('uncaughtException', function () {
process.send({act: 'suicide'});
worker.close(function () {
process.exit(1);
});
});
在主进程中,创建新线程的时机也提前:
worker.on('message', function (message) {
if (message.act === 'suicide') {
console.log(worker.pid+'suicide')
createWorker();
}
});
worker.on('exit', function () {
console.log('Worker ' + worker.pid + ' exited.');
delete workers[worker.pid];
});
如果我们处理的是长连接,等待长连接断开的时间会比较久,设置一个超时是合理的:
process.on('uncaughtException', function () {
process.send({act: 'suicide'});
worker.close(function () {
process.exit(1);
});
setTimeout(function () {
process.exit(1);
}, 5000);
});
限量重启
通过自杀信号告知主进程可以使得新连接总是有进程服务,但是还是存在一种极端情况,就是程序是有问题的,这就会导致刚一启动就发生错误,或一有连接就发生错误,这样会导致短时间内大量重启进程,这是我们不希望看到的。
我们在每次创建新的进程前先检查一下,是否创建的太过频繁:
var limit = 20;
// 时间单位
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;
};
负载均衡
这个是一个比较复杂的问题,自己实现比价复杂,Node在专门处理多进程的模块cluster中设置了策略可以直接启用:
cluster.schedulingPolicy = cluster.SCHED_RR
这个策略叫做Round-Robin,轮叫调度,这个的工作方式是由主进程接受连接,将其依次分发给工作进程,分发的策略是在N个工作进程中,每次选择第i = (i + 1) mod n个进程发送连接。
状态共享
多个线程可能需要共享一些数据,比如配置文件等。将这些要共享的东西放在第三方,比如缓存里,有一个单独的进程负责轮询,看这些要共享的东西是否发生了变化,如果有就通知所有工作进程到缓存里去取。
Cluster模块
这个模块就是child_process模块和net模块的组合使用,具体的就不介绍了,可以去看官方文档。