本文首發於貓眼前端團隊公眾號,轉載請註明出處。
前言
如果大家用 PM2 管理 Node.js 程式,會發現它支援一種 cluster mode。開啟 cluster mode 後,支援給 Node.js 建立多個程式。 如果將 cluster mode 下的 instances 設定為 max 的話,它還會根據伺服器的 CPU 核心數,來建立對應數量的 Node 程式。

PM2 其實利用的是 Node.js Cluster 模組來實現的,這個模組的出現就是為了解決 Node.js 例項單執行緒執行,無法利用多核 CPU 的優勢而出現的。那麼,Cluster 內部又是如何工作的呢?多個程式間是如何通訊的?多個程式是如何監聽同一個埠的?Node.js 是如何將請求分發到各個程式上的?如果你對上述問題還不清楚,不妨接著往下看。
核心原理
Node.js worker 程式由child_process.fork()
方法建立,這也意味存在著父程式和多個子程式。程式碼大致是這樣:
const cluster = require('cluster');
const os = require('os');
if (cluster.isMaster) {
for (var i = 0, n = os.cpus().length; i < n; i += 1) {
cluster.fork();
}
} else {
// 啟動程式
}
複製程式碼
學過作業系統的同學,應該對 fork() 這個系統呼叫不陌生,呼叫它的程式為父程式,fork 出來的都是子程式。子程式和父程式具有相同的程式碼段、資料段、堆疊,但是它們的記憶體空間不共享。父程式(即 master 程式)負責監聽埠,接收到新的請求後將其分發給下面的 worker 程式。這裡涉及三個問題:父子程式通訊、負載均衡策略以及多程式的埠監聽。
備註:Linux 上 fork() 支援寫時複製,只有程式空間的各段的內容要發生變化時,才會將父程式的內容複製一份給子程式。因此子程式和父程式一開始是共享相同的記憶體空間。
程式通訊
master 程式通過 process.fork() 建立子程式,他們之間通過 IPC (內部程式通訊)通道實現通訊。作業系統的程式間通訊方式主要有以下幾種:
- 共享記憶體 不同程式共享同一段記憶體空間。通常還需要引入訊號量機制,來實現同步與互斥。
- 訊息傳遞 這種模式下,程式間通過傳送、接收訊息來實現資訊的同步。
- 訊號量 訊號量簡單說就是系統賦予程式的一個狀態值,未得到控制權的程式會在特定地方被強迫停下來,等待可以繼續進行的訊號到來。如果訊號量只有 0 或者 1 兩個值的話,又被稱作“互斥鎖”。這個機制也被廣泛用於各種程式設計模式中。
- 管道 管道本身也是一個程式,它用於連線兩個程式,將一個程式的輸出作為另一個程式的輸入。可以用 pipe 系統呼叫來建立管道。我們經常用的“ | ”命令列就是利用了管道機制。
Node.js 為父子程式的通訊提供了事件機制來傳遞訊息。下面的例子實現了父程式將 TCP server 物件控制程式碼傳給子程式。
const subprocess = require('child_process').fork('subprocess.js');
// 開啟 server 物件,併傳送該控制程式碼。
const server = require('net').createServer();
server.on('connection', (socket) => {
socket.end('被父程式處理');
});
server.listen(1337, () => {
subprocess.send('server', server);
});
複製程式碼
process.on('message', (m, server) => {
if (m === 'server') {
server.on('connection', (socket) => {
socket.end('被子程式處理');
});
}
});
複製程式碼
那麼問題又來了,如果程式間沒有父子關係,換句話說,我們應該如何實現任意程式間的通訊呢?大家可以去看看這篇文章:程式間通訊的另類實現
負載均衡策略
前面提到,所有請求是通過 master 程式分配的,要保證伺服器負載比較均衡的分配到各個 worker 程式上,這就涉及到負載均衡策略了。Node.js 預設採用的策略是 round-robin 時間片輪轉法。
round-robin 是一種很常見的負載均衡演算法,Nginx 上也採用了它作為負載均衡策略之一。它的原理很簡單,每一次把來自使用者的請求輪流分配給各個程式,從 1 開始,直到 N(worker 程式個數),然後重新開始迴圈。這個演算法的問題在於,它是假定各個程式或者說各個伺服器的處理效能是一樣的,但是如果請求處理間隔較長,就容易導致出現負載不均衡。因此我們通常在 Nginx 上採用另一種演算法:WRR,加權輪轉法。通過給各個伺服器分配一定的權重,每次選出權重最大的,給其權重減 1,直到權重全部為 0 後,按照此時生成的序列輪詢。
可以通過設定 NODE_CLUSTER_SCHED_POLICY 環境變數,或者通過 cluster.setupMaster(options) 來修改負載均衡策略。讀到這裡大家可以發現,我們可以 Nginx 做多機器叢集上的負載均衡,然後用 Node.js Cluster 來實現單機多程式上的負載均衡。
多程式的埠監聽
最初的 Node.js 上,多個程式監聽同一個埠,它們相互競爭新 accept 過來的連線。這樣會導致各個程式的負載很不均衡,於是後來使用了上文提到的 round-robin 策略。具體思路是,master 程式建立 socket,繫結地址並進行監聽。該 socket 的 fd 不傳遞到各個 worker 程式。當 master 程式獲取到新的連線時,再決定將 accept 到的客戶端連線分發給指定的 worker 處理。簡單說就是,master 程式監聽埠,然後將連線通過某種分發策略(比如 round-robin),轉發給 worker 程式。這樣由於只有 master 程式接收客戶端連線,就解決了競爭導致的負載不均衡的問題。但是這樣設計就要求 master 程式的穩定性足夠好了。
總結
本文以 PM2 的 Cluster Mode 作為切入點,向大家介紹了 Node.js Cluster 實現多程式的核心原理。重點講了程式通訊、負載均衡以及多程式埠監聽三個方面。通過研究 cluster 模組可以發現,很多底層原理或者是演算法,其實都是通用的。比如 round-robin 演算法,它在作業系統底層的程式排程中也有使用;比如 master-worker 這種架構,是不是在 Nginx 的多程式架構中也似曾相識;比如訊號量、管道這些機制,也可以在各種程式設計模式中見到它們的身影。當下市面上各種新技術層出不窮,但核心其實是萬變不離其宗,理解了這些最基礎的知識,剩下的也可以觸類旁通了。
參考連結: