文:正龍(滬江網校Web前端工程師)javascript
本文原創,轉載請註明做者及出處html
以前的文章「走進Node.js之HTTP實現分析」中,你們已經瞭解 Node.js 是如何處理 HTTP 請求的,在整個處理過程,它僅僅用到單進程模型。那麼如何讓 Web 應用擴展到多進程模型,以便充分利用CPU資源呢?答案就是 Cluster。本篇文章將帶着你們一塊兒分析Node.js的多進程模型。前端
首先,來一段經典的 Node.js 主從服務模型代碼:java
const cluster = require('cluster');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
} else {
require('http').createServer((req, res) => {
res.end('hello world');
}).listen(3333);
}
複製代碼
一般,主從模型包含一個主進程(master)和多個從進程(worker),主進程負責接收鏈接請求,以及把單個的請求任務分發給從進程處理;從進程的職責就是不斷響應客戶端請求,直至進入等待狀態。如圖 3-1 所示:node
圍繞這段代碼,本文但願講述清楚幾個關鍵問題:linux
從進程的建立過程;c++
在使用同一主機地址的前提下,若是指定端口已經被監聽,其它進程嘗試監聽同一端口時本應該會報錯(EADDRINUSE,即端口已被佔用);那麼,Node.js 如何可以在主從進程上對同一端口執行 listen 方法?git
在 Node.js 中,cluster.fork 與 POSIX 的 fork 略有不一樣:雖然從進程仍舊是 fork 建立,可是並不會直接使用主進程的進程映像,而是調用系統函數 execvp 讓從進程使用新的進程映像。另外,每一個從進程對應一個 Worker 對象,它有以下狀態:none、online、listening、dead和disconnected。github
ChildProcess 對象主要提供進程的建立(spawn)、銷燬(kill)以及進程句柄引用計數管理(ref 與 unref)。在對Process對象(process_wrap.cc)進行封裝以外,它自身也處理了一些細節問題。例如,在方法 spawn 中,若是須要主從進程之間創建 IPC 管道,則經過環境變量 NODE_CHANNEL_FD 來告知從進程應該綁定的 IPC 相關的文件描述符(fd),這個特殊的環境變量後面會被再次涉及到。json
以上提到的三個對象引用關係以下:
cluster.fork 的主要執行流程:
調用 child_process.spawn;
建立 ChildProcess 對象,並初始化其 _handle 屬性爲 Process 對象;Process 是 process_wrap.cc 中公佈給 JavaScript 的對象,它封裝了 libuv 的進程操縱功能。附上 Process 對象的 C++ 定義:
interface Process {
construtor(const FunctionCallbackInfo<Value>& args);
void close(const FunctionCallbackInfo<Value>& args);
void spawn(const FunctionCallbackInfo<Value>& args);
void kill(const FunctionCallbackInfo<Value>& args);
void ref(const FunctionCallbackInfo<Value>& args);
void unref(const FunctionCallbackInfo<Value>& args);
void hasRef(const FunctionCallbackInfo<Value>& args);
}
複製代碼
主進程在執行 cluster.fork 時,會指定兩個特殊的環境變量 NODE_CHANNEL_FD 和 NODE_UNIQUE_ID,因此從進程的初始化過程跟通常 Node.js 進程略有不一樣:
bootstrap_node.js 是運行時包含的 JavaScript 入口文件,其中調用 internal\process.setupChannel;
若是環境變量包含 NODE_CHANNEL_FD,則調用 child_process._forkChild,而後移除該值;
調用 internal\child_process.setupChannel,在子進程的全局 process 對象上監聽消息 internalMessage,而且添加方法 send 和 _send。其中 send 只是對 _send 的封裝;一般,_send 只是把消息 JSON 序列化以後寫入管道,並最終投遞到接收端。
若是環境變量包含 NODE_UNIQUE_ID,則當前進程是 worker 模式,加載 cluster 模塊時會執行 workerInit;另外,它也會影響到 net.Server 的 listen 方法,worker 模式下 listen 方法會調用 cluster._getServer,該方法實質上向主進程發起消息 {"act" : "queryServer"},而不是真正監聽端口。
上文提到了 Node.js 主從進程僅僅經過 IPC 維持聯絡,那這一節就來深刻分析下 IPC 的實現細節。首先,讓咱們看一段示例代碼:
1-master.js
const {spawn} = require('child_process');
let child = spawn(process.execPath, [`${__dirname}/1-slave.js`], {
stdio: [0, 1, 2, 'ipc']
});
child.on('message', function(data) {
console.log('received in master:');
console.log(data);
});
child.send({
msg: 'msg from master'
});
複製代碼
1-slave.js
process.on('message', function(data) {
console.log('received in slave:');
console.log(data);
});
process.send({
'msg': 'message from slave'
});
複製代碼
node 1-master.js
複製代碼
運行結果以下:
細心的同窗可能發現控制檯輸出並非連續的,master和slave的日誌交錯打印,這是因爲並行進程執行順序不可預知形成的。
前文提到從進程實際上經過系統調用 execvp 啓動新的 Node.js 實例;也就是說默認狀況下,Node.js 主從進程不會共享文件描述符表,那它們究竟是如何互發消息的呢?
原來,能夠利用 socketpair 建立一對全雙工匿名 socket,用於在進程間互發消息;其函數簽名以下:
int socketpair(int domain, int type, int protocol, int sv[2]);
複製代碼
一般狀況下,咱們是沒法經過 socket 來傳遞文件描述符的;當主進程與客戶端創建了鏈接,須要把鏈接描述符告知從進程處理,怎麼辦?其實,經過指定 socketpair 的第一個參數爲 AF_UNIX,表示建立匿名 UNIX 域套接字(UNIX domain socket),這樣就可使用系統函數 sendmsg 和 recvmsg 來傳遞/接收文件描述符了。
主進程在調用 cluster.fork 時,相關流程以下:
至此,主從進程就能夠進行雙向通訊了。流程圖以下:
咱們再回看一下環境變量 NODE_CHANNEL_FD,使人疑惑的是,它的值始終爲3。進程級文件描述符表中,0-2分別是標準輸入stdin、標準輸出stdout和標準錯誤輸出stderr,那麼可用的第一個文件描述符就是3,socketpair 顯然會佔用從進程的第一個可用文件描述符。這樣,當從進程往 fd=3 的流中寫入數據時,主進程就能夠收到消息;反之,亦相似。
從 IPC 讀取消息主要是流操做,之後有機會詳解,下面列出主要流程:
StreamBase::EditData 回調 onread;
StreamWrap::OnReadImpl 調用 StreamWrap::EditData;
StreamWrap 的構造函數會調用 set_read_cb 設置 OnReadImpl;
StreamWrap::set_read_cb 設置屬性 StreamWrap::read_cb_;
StreamWrap::OnRead 中引用屬性 read_cb_;
StreamWrap::ReadStart 調用 uv_read_start 時傳遞 Streamwrap::OnRead 做爲第3個參數:
int uv_read_start(uv_stream_t* stream, uv_alloc_cb alloc_cb, uv_read_cb read_cb) 複製代碼
涉及到的類圖關係以下:
以上大概分析了從進程的建立過程及其特殊性;若是要實現主從服務模型的話,還須要解決一個基本問題:從進程怎麼獲取到與客戶端間的鏈接描述符?咱們打算從 process.send(只有在從進程的全局 process 對象上纔有 send 方法,主進程能夠經過 worker.process 或 worker 訪問該方法)的函數簽名着手:
void send(message, sendHandle, callback)
複製代碼
其參數 message 和 callback 含義也許顯而易見,分別指待發送的消息對象和操做結束以後的回調函數。那它的第二個參數 sendHandle 用途是什麼?
前文提到系統函數 socketpair 能夠建立一對雙向 socket,可以用來發送 JSON 消息,這一塊主要涉及到流操做;另外,當 sendHandle 有值時,它們還能夠用於傳遞文件描述符,其過程要相對複雜一些,可是最終會調用系統函數 sendmsg 以及 recvmsg。
在主從服務模型下,主進程負責跟客戶端創建鏈接,而後把鏈接描述符經過 sendmsg 傳遞給從進程。咱們來看看這一過程:
從進程
調用 http.Server.listen 方法(繼承至 net.Server);
調用 cluster._getServer,向主進程發起消息:
{
"cmd": "NODE_HANDLE",
"msg": {
"act": "queryServer"
}
}
複製代碼
主進程
class RoundRobinHandle {
construtor(key, address, port, addressType, fd) {
// 監聽同一端點的從進程集合
this.all = [];
// 可用的從進程集合
this.free = [];
// 當前等待處理的客戶端鏈接描述符集合
this.handles = [];
// 指定端點的TCP服務socket
this.server = null;
}
add(worker, send) {
// 把從進程實例加入this.all
}
remove(worker) {
// 移除指定從進程
}
distribute(err, handle) {
// 把鏈接描述符handle存入this.handles,並指派一個可用的從進程實例開始處理鏈接請求
}
handoff(worker) {
// 從this.handles中取出一個待處理的鏈接描述符,並向從進程發起消息
// {
// "type": "NODE_HANDLE",
// "msg": {
// "act": "newconn",
// }
// }
}
}
複製代碼
調用 handle.add 方法,把 worker 對象添加到 handle.all 集合中;
當 handle.server 開始監聽客戶端請求以後,重置其 onconnection 回調函數爲 RoundRobinHandle.distribute,這樣的話主進程就不用實際處理客戶端鏈接,只要分發鏈接給從進程處理便可。它會把鏈接描述符存入 handle.handles 集合,當有可用 worker 時,則向其發送消息 { "act": "newconn" }。若是被指派的 worker 沒有回覆確認消息 { "ack": message.seq, accepted: true },則會嘗試把該鏈接分配給其餘 worker。
流程圖以下:
從進程上調用listen
客戶端鏈接處理
緣由主要有兩點:
** I. 從進程中 Node.js 運行時的初始化略有不一樣**
由於從進程存在環境變量 NODE_UNIQUE_ID,因此在 bootstrap_node.js 中,加載 cluster 模塊時執行 workerInit 方法。這個地方與主進程執行的 masterInit 方法不一樣點在於:其一,從進程上沒有 cluster.fork 方法,因此不能在從進程繼續建立子孫進程;其二,Worker 對象上的方法 disconnect 和 destroy 實現也有所差別:咱們以調用 worker.destroy 爲例,在主進程上時,不能直接把從進程殺掉,而是通知從進程退出,而後再把它從集合裏刪除;當在從進程上時,從進程通知完主進程而後退出就能夠了;其三,從進程上 cluster 模塊新增了方法 _getServer,用於向主進程發起消息 {"act": "queryServer"},通知主進程建立 RoundRobinHandle 對象,並實際監聽指定端口地址;而後自身用一個模擬的 TCP 描述符繼續執行;
調用 cluster._setupWorker 方法,主要是初始化 cluster.worker 屬性,並監聽消息 internalMessage,處理兩種消息類型:newconn 和 disconnect;
向主進程發起消息 { "act": "online" };
由於從進程額環境變量中有 NODE_CHANNEL_FD,調用 internal\process.setupChannel時,會鏈接到系統函數 socketpair 建立的雙向 socket ,並監聽 internalMessage ,處理消息類型:NODE_HANDLE_ACK和NODE_HANDLE。
** II. listen 方法在主從進程中執行的代碼略有不一樣。**
在 net.Server(net.js)的方法 listen 中,若是是主進程,則執行標準的端口綁定流程;若是是從進程,則會調用 cluster._getServer,參見上面對該方法的描述。
最後,附上基於libuv實現的一個 C 版 Master-Slave 服務模型,GitHub地址。
啓動服務器以後,訪問 http://localhost:3333 的運行結果以下:
相信經過本篇文章的介紹,你們已經對Node.js的Cluster有了一個全面的瞭解。下一次做者會跟你們一塊兒深刻分析Node.js進程管理在生產環境下的可用性問題,敬請期待。
2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!