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模塊的組合使用,具體的就不介紹了,可以去看官方文檔。