文:正龍(滬江網校Web前端工程師)本文原創,轉載請註明做者及出處javascript
以前的文章「走進Node.js之HTTP實現分析」中,你們已經瞭解 Node.js 是如何處理 HTTP 請求的,在整個處理過程,它僅僅用到單進程模型。那麼如何讓 Web 應用擴展到多進程模型,以便充分利用CPU資源呢?答案就是 Cluster。本篇文章將帶着你們一塊兒分析Node.js的多進程模型。html
首先,來一段經典的 Node.js 主從服務模型代碼:前端
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 所示:java
圍繞這段代碼,本文但願講述清楚幾個關鍵問題:node
在 Node.js 中,cluster.fork 與 POSIX 的 fork 略有不一樣:雖然從進程仍舊是 fork 建立,可是並不會直接使用主進程的進程映像,而是調用系統函數 execvp 讓從進程使用新的進程映像。另外,每一個從進程對應一個 Worker 對象,它有以下狀態:none、online、listening、dead和disconnected。linux
ChildProcess 對象主要提供進程的建立(spawn)、銷燬(kill)以及進程句柄引用計數管理(ref 與 unref)。在對Process對象(process_wrap.cc)進行封裝以外,它自身也處理了一些細節問題。例如,在方法 spawn 中,若是須要主從進程之間創建 IPC 管道,則經過環境變量 NODE_CHANNEL_FD 來告知從進程應該綁定的 IPC 相關的文件描述符(fd),這個特殊的環境變量後面會被再次涉及到。c++
以上提到的三個對象引用關係以下:git
cluster.fork 的主要執行流程:github
建立 ChildProcess 對象,並初始化其 _handle 屬性爲 Process 對象;Process 是 process_wrap.cc 中公佈給 JavaScript 的對象,它封裝了 libuv 的進程操縱功能。附上 Process 對象的 C++ 定義:json
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 進程略有不一樣:
上文提到了 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 讀取消息主要是流操做,之後有機會詳解,下面列出主要流程:
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 傳遞給從進程。咱們來看看這一過程:
從進程
調用 cluster._getServer,向主進程發起消息:
{ "cmd": "NODE_HANDLE", "msg": { "act": "queryServer" } }
主進程
接收處理這個消息時,會新建一個 RoundRobinHandle 對象,爲變量 handle。每一個 handle 與一個鏈接端點對應,而且對應多個從進程實例;同時,它會開啓與鏈接端點相應的 TCP 服務 socket。
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", // } // } } }
流程圖以下:
從進程上調用listen
客戶端鏈接處理
緣由主要有兩點:
I. 從進程中 Node.js 運行時的初始化略有不一樣
II. listen 方法在主從進程中執行的代碼略有不一樣。
在 net.Server(net.js)的方法 listen 中,若是是主進程,則執行標準的端口綁定流程;若是是從進程,則會調用 cluster._getServer,參見上面對該方法的描述。
最後,附上基於libuv實現的一個 C 版 Master-Slave 服務模型,GitHub地址。
啓動服務器以後,訪問 http://localhost:3333 的運行結果以下:
相信經過本篇文章的介紹,你們已經對Node.js的Cluster有了一個全面的瞭解。下一次做者會跟你們一塊兒深刻分析Node.js進程管理在生產環境下的可用性問題,敬請期待。