淺析 NodeJS 多進(jìn)程和集群
進(jìn)程
進(jìn)程是指在系統(tǒng)中正在運行的一個應(yīng)用程序。
當(dāng)我們打開活動監(jiān)視器或者文件資源管理器時,可以看到每一個正在運行的進(jìn)程:

多進(jìn)程
復(fù)制進(jìn)程
NodeJS 提供了 child_process 模塊,并且提供了 child_process.fork() 函數(shù)供我們復(fù)制進(jìn)程。
舉個??
在一個目錄下新建 worker.js 和 master.js 兩個文件:
worker.js
const http = require('http');
http.createServer((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello NodeJS!\n');
}).listen(Math.round((1 + Math.random()) * 2000), '127.0.0.1');
master.js
const { fork } = require('child_process');
const { cpus } = require('os');
cpus().map(() => {
fork('./worker.js');
});
通過 node master.js 啟動 master.js,然后通過 ps aux | grep worker.js 查看進(jìn)程的數(shù)量,我們可以發(fā)現(xiàn),理想狀況下,進(jìn)程的數(shù)量等于 CPU 的核心數(shù),每個進(jìn)程各自利用一個 CPU,實現(xiàn)多核 CPU 的利用:

這是經(jīng)典的 Master-Worker 模式(主從模式)
實際上,fork 進(jìn)程是昂貴的,復(fù)制進(jìn)程的目的是充分利用 CPU 資源,所以 NodeJS 在單線程上使用了事件驅(qū)動的方式來解決高并發(fā)的問題。
子進(jìn)程的創(chuàng)建
child_process 模塊提供了四個方法創(chuàng)建子進(jìn)程:
child_process.spawn(command, args) child_process.exec(command, options) child_process.execFile(file, args[, callback]) child_process.fork(modulePath, args)
對比:

后三種方法都是 spawn() 的延伸。
進(jìn)程間的通信
在 NodeJS 中,子進(jìn)程對象使用 send() 方法實現(xiàn)主進(jìn)程向子進(jìn)程發(fā)送數(shù)據(jù),message 事件實現(xiàn)主進(jìn)程收聽由子進(jìn)程發(fā)來的數(shù)據(jù)。
舉個??
在一個目錄下新建 parent.js 和 child.js 兩個文件:
parent.js
const { fork } = require('child_process');
const sender = fork(__dirname + '/child.js');
sender.on('message', msg => {
console.log('主進(jìn)程收到子進(jìn)程的信息:', msg);
});
sender.send('Hey! 子進(jìn)程');
child.js
process.on('message', msg => {
console.log('子進(jìn)程收到來自主進(jìn)程的信息:', msg);
});
process.send('Hey! 主進(jìn)程');
當(dāng)我們執(zhí)行 node parent.js 時,會出現(xiàn)如下圖所示:

這樣我們就實現(xiàn)了一個最基本的進(jìn)程間通信。
IPC
IPC 即進(jìn)程間通信,可以讓不同進(jìn)程之間能夠相互訪問資源并協(xié)調(diào)工作。
實際上,父進(jìn)程會在創(chuàng)建子進(jìn)程之前,會先創(chuàng)建 IPC 通道并監(jiān)聽這個 IPC,然后再創(chuàng)建子進(jìn)程,通過環(huán)境變量(NODE_CHANNEL_FD)告訴子進(jìn)程和 IPC 通道相關(guān)的文件描述符,子進(jìn)程啟動的時候根據(jù)文件描述符連接 IPC 通道,從而和父進(jìn)程建立連接。

句柄傳遞
句柄是一種可以用來標(biāo)識資源的引用的,它的內(nèi)部包含了指向?qū)ο蟮奈募Y源描述符。
一般情況下,當(dāng)我們想要將多個進(jìn)程監(jiān)聽到一個端口下,可能會考慮使用主進(jìn)程代理的方式處理:
然而,這種代理方案會導(dǎo)致每次請求的接收和代理轉(zhuǎn)發(fā)用掉兩個文件描述符,而系統(tǒng)的文件描述符是有限的,這種方式會影響系統(tǒng)的擴(kuò)展能力。
所以,為什么要使用句柄?原因是在實際應(yīng)用場景下,建立 IPC 通信后可能會涉及到比較復(fù)雜的數(shù)據(jù)處理場景,句柄可以作為 send() 方法的第二個可選參數(shù)傳入,也就是說可以直接將資源的標(biāo)識通過 IPC 傳輸,避免了上面所說的代理轉(zhuǎn)發(fā)造成的文件描述符的使用。

以下是支持發(fā)送的句柄類型:
net.Socket net.Server net.Native dgram.Socket dgram.Native
句柄發(fā)送與還原
NodeJS 進(jìn)程之間只有消息傳遞,不會真正的傳遞對象。
send() 方法在發(fā)送消息前,會將消息組裝成 handle 和 message,這個 message 會經(jīng)過 JSON.stringify 序列化,也就是說,傳遞句柄的時候,不會將整個對象傳遞過去,在 IPC 通道傳輸?shù)亩际亲址瑐鬏敽笸ㄟ^ JSON.parse 還原成對象。
監(jiān)聽共同端口
上圖所示,為什么多個進(jìn)程可以監(jiān)聽同一個端口呢?
原因是主進(jìn)程通過 send() 方法向多個子進(jìn)程發(fā)送屬于該主進(jìn)程的一個服務(wù)對象的句柄,所以對于每一個子進(jìn)程而言,它們在還原句柄之后,得到的服務(wù)對象是一樣的,當(dāng)網(wǎng)絡(luò)請求向服務(wù)端發(fā)起時,進(jìn)程服務(wù)是搶占式的,所以監(jiān)聽相同端口時不會引起異常。
Cluster
引用 Egg.js 官方對 Cluster 的理解:
在服務(wù)器上同時啟動多個進(jìn)程。 每個進(jìn)程里都跑的是同一份源代碼(好比把以前一個進(jìn)程的工作分給多個進(jìn)程去做)。 更神奇的是,這些進(jìn)程可以同時監(jiān)聽一個端口
其中:
負(fù)責(zé)啟動其他進(jìn)程的叫做 Master 進(jìn)程,他好比是個『包工頭』,不做具體的工作,只負(fù)責(zé)啟動其他進(jìn)程。 其他被啟動的叫 Worker 進(jìn)程,顧名思義就是干活的『工人』。它們接收請求,對外提供服務(wù)。 Worker 進(jìn)程的數(shù)量一般根據(jù)服務(wù)器的 CPU 核數(shù)來定,這樣就可以完美利用多核資源。
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
// Fork workers.
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', function(worker, code, signal) {
console.log('worker ' + worker.process.pid + ' died');
});
} else {
// Workers can share any TCP connection
// In this case it is an HTTP server
http.createServer(function(req, res) {
res.writeHead(200);
res.end("hello world\n");
}).listen(8000);
}
簡單來說,cluster 模塊是 child_process 模塊和 net 模塊的組合應(yīng)用。
cluster 啟動時,內(nèi)部會啟動 TCP 服務(wù)器,將這個 TCP 服務(wù)器端 socket 的文件描述符發(fā)給工作進(jìn)程。
在 cluster 模塊應(yīng)用中,一個主進(jìn)程只能管理一組工作進(jìn)程,其運作模式?jīng)]有 child_process 模塊那么靈活,但是更加穩(wěn)定:

為了讓集群更加穩(wěn)定和健壯,cluster 模塊也暴露了許多事件:
fork online listening disconnect exit setup
這些事件在進(jìn)程間消息傳遞的基礎(chǔ)了完成了封裝,保證了集群的穩(wěn)定性和健壯性。
進(jìn)程守護(hù)#
未捕獲異常
當(dāng)代碼拋出了異常沒有被捕獲到時,進(jìn)程將會退出,此時 Node.js 提供了 process.on('uncaughtException', handler) 接口來捕獲它,但是當(dāng)一個 Worker 進(jìn)程遇到未捕獲的異常時,它已經(jīng)處于一個不確定狀態(tài),此時我們應(yīng)該讓這個進(jìn)程優(yōu)雅退出:
關(guān)閉異常 Worker 進(jìn)程所有的 TCP Server(將已有的連接快速斷開,且不再接收新的連接),斷開和 Master 的 IPC 通道,不再接受新的用戶請求。 Master 立刻 fork 一個新的 Worker 進(jìn)程,保證在線的『工人』總數(shù)不變。 異常 Worker 等待一段時間,處理完已經(jīng)接受的請求后退出。
+---------+ +---------+
| Worker | | Master |
+---------+ +----+----+
| uncaughtException |
+------------+ |
| | | +---------+
| <----------+ | | Worker |
| | +----+----+
| disconnect | fork a new worker |
+-------------------------> + ---------------------> |
| wait... | |
| exit | |
+-------------------------> | |
| | |
die | |
| |
| |
OOM、系統(tǒng)異常
當(dāng)一個進(jìn)程出現(xiàn)異常導(dǎo)致 crash 或者 OOM 被系統(tǒng)殺死時,不像未捕獲異常發(fā)生時我們還有機(jī)會讓進(jìn)程繼續(xù)執(zhí)行,只能夠讓當(dāng)前進(jìn)程直接退出,Master 立刻 fork 一個新的 Worker。
參考資料
《深入淺出 Node.js》 Node.js 中文文檔[1] Egg.js 官方文檔[2]
參考資料
Node.js 中文文檔: http://nodejs.cn/api/
[2]Egg.js 官方文檔: https://eggjs.org/zh-cn/core/cluster-and-ipc.html
內(nèi)推社群
我組建了一個氛圍特別好的騰訊內(nèi)推社群,如果你對加入騰訊感興趣的話(后續(xù)有計劃也可以),我們可以一起進(jìn)行面試相關(guān)的答疑、聊聊面試的故事、并且在你準(zhǔn)備好的時候隨時幫你內(nèi)推。下方加 winty 好友回復(fù)「面試」即可。
