摘要:例如,在方法中,如果需要主從進(jìn)程之間建立管道,則通過環(huán)境變量來告知從進(jìn)程應(yīng)該綁定的相關(guān)的文件描述符,這個(gè)特殊的環(huán)境變量后面會(huì)被再次涉及到。
文:正龍(滬江網(wǎng)校Web前端工程師)本文原創(chuàng),轉(zhuǎn)載請(qǐng)注明作者及出處
之前的文章“走進(jìn)Node.js之HTTP實(shí)現(xiàn)分析”中,大家已經(jīng)了解 Node.js 是如何處理 HTTP 請(qǐng)求的,在整個(gè)處理過程,它僅僅用到單進(jìn)程模型。那么如何讓 Web 應(yīng)用擴(kuò)展到多進(jìn)程模型,以便充分利用CPU資源呢?答案就是 Cluster。本篇文章將帶著大家一起分析Node.js的多進(jìn)程模型。
首先,來一段經(jīng)典的 Node.js 主從服務(wù)模型代碼:
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); }
通常,主從模型包含一個(gè)主進(jìn)程(master)和多個(gè)從進(jìn)程(worker),主進(jìn)程負(fù)責(zé)接收連接請(qǐng)求,以及把單個(gè)的請(qǐng)求任務(wù)分發(fā)給從進(jìn)程處理;從進(jìn)程的職責(zé)就是不斷響應(yīng)客戶端請(qǐng)求,直至進(jìn)入等待狀態(tài)。如圖 3-1 所示:
圍繞這段代碼,本文希望講述清楚幾個(gè)關(guān)鍵問題:
從進(jìn)程的創(chuàng)建過程;
在使用同一主機(jī)地址的前提下,如果指定端口已經(jīng)被監(jiān)聽,其它進(jìn)程嘗試監(jiān)聽同一端口時(shí)本應(yīng)該會(huì)報(bào)錯(cuò)(EADDRINUSE,即端口已被占用);那么,Node.js 如何能夠在主從進(jìn)程上對(duì)同一端口執(zhí)行 listen 方法?
進(jìn)程 fork 是如何完成的?在 Node.js 中,cluster.fork 與 POSIX 的 fork 略有不同:雖然從進(jìn)程仍舊是 fork 創(chuàng)建,但是并不會(huì)直接使用主進(jìn)程的進(jìn)程映像,而是調(diào)用系統(tǒng)函數(shù) execvp 讓從進(jìn)程使用新的進(jìn)程映像。另外,每個(gè)從進(jìn)程對(duì)應(yīng)一個(gè) Worker 對(duì)象,它有如下狀態(tài):none、online、listening、dead和disconnected。
ChildProcess 對(duì)象主要提供進(jìn)程的創(chuàng)建(spawn)、銷毀(kill)以及進(jìn)程句柄引用計(jì)數(shù)管理(ref 與 unref)。在對(duì)Process對(duì)象(process_wrap.cc)進(jìn)行封裝之外,它自身也處理了一些細(xì)節(jié)問題。例如,在方法 spawn 中,如果需要主從進(jìn)程之間建立 IPC 管道,則通過環(huán)境變量 NODE_CHANNEL_FD 來告知從進(jìn)程應(yīng)該綁定的 IPC 相關(guān)的文件描述符(fd),這個(gè)特殊的環(huán)境變量后面會(huì)被再次涉及到。
以上提到的三個(gè)對(duì)象引用關(guān)系如下:
cluster.fork 的主要執(zhí)行流程:
調(diào)用 child_process.spawn;
創(chuàng)建 ChildProcess 對(duì)象,并初始化其 _handle 屬性為 Process 對(duì)象;Process 是 process_wrap.cc 中公布給 JavaScript 的對(duì)象,它封裝了 libuv 的進(jìn)程操縱功能。附上 Process 對(duì)象的 C++ 定義:
interface Process { construtor(const FunctionCallbackInfo& args); void close(const FunctionCallbackInfo & args); void spawn(const FunctionCallbackInfo & args); void kill(const FunctionCallbackInfo & args); void ref(const FunctionCallbackInfo & args); void unref(const FunctionCallbackInfo & args); void hasRef(const FunctionCallbackInfo & args); }
調(diào)用 ChildProcess._handle 的方法 spawn,并會(huì)最終調(diào)用 libuv 庫中 uv_spawn。
主進(jìn)程在執(zhí)行 cluster.fork 時(shí),會(huì)指定兩個(gè)特殊的環(huán)境變量 NODE_CHANNEL_FD 和 NODE_UNIQUE_ID,所以從進(jìn)程的初始化過程跟一般 Node.js 進(jìn)程略有不同:
bootstrap_node.js 是運(yùn)行時(shí)包含的 JavaScript 入口文件,其中調(diào)用 internalprocess.setupChannel;
如果環(huán)境變量包含 NODE_CHANNEL_FD,則調(diào)用 child_process._forkChild,然后移除該值;
調(diào)用 internalchild_process.setupChannel,在子進(jìn)程的全局 process 對(duì)象上監(jiān)聽消息 internalMessage,并且添加方法 send 和 _send。其中 send 只是對(duì) _send 的封裝;通常,_send 只是把消息 JSON 序列化之后寫入管道,并最終投遞到接收端。
如果環(huán)境變量包含 NODE_UNIQUE_ID,則當(dāng)前進(jìn)程是 worker 模式,加載 cluster 模塊時(shí)會(huì)執(zhí)行 workerInit;另外,它也會(huì)影響到 net.Server 的 listen 方法,worker 模式下 listen 方法會(huì)調(diào)用 cluster._getServer,該方法實(shí)質(zhì)上向主進(jìn)程發(fā)起消息 {"act" : "queryServer"},而不是真正監(jiān)聽端口。
IPC實(shí)現(xiàn)細(xì)節(jié)上文提到了 Node.js 主從進(jìn)程僅僅通過 IPC 維持聯(lián)絡(luò),那這一節(jié)就來深入分析下 IPC 的實(shí)現(xiàn)細(xì)節(jié)。首先,讓我們看一段示例代碼:
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
運(yùn)行結(jié)果如下:
細(xì)心的同學(xué)可能發(fā)現(xiàn)控制臺(tái)輸出并不是連續(xù)的,master和slave的日志交錯(cuò)打印,這是由于并行進(jìn)程執(zhí)行順序不可預(yù)知造成的。
socketpair前文提到從進(jìn)程實(shí)際上通過系統(tǒng)調(diào)用 execvp 啟動(dòng)新的 Node.js 實(shí)例;也就是說默認(rèn)情況下,Node.js 主從進(jìn)程不會(huì)共享文件描述符表,那它們到底是如何互發(fā)消息的呢?
原來,可以利用 socketpair 創(chuàng)建一對(duì)全雙工匿名 socket,用于在進(jìn)程間互發(fā)消息;其函數(shù)簽名如下:
int socketpair(int domain, int type, int protocol, int sv[2]);
通常情況下,我們是無法通過 socket 來傳遞文件描述符的;當(dāng)主進(jìn)程與客戶端建立了連接,需要把連接描述符告知從進(jìn)程處理,怎么辦?其實(shí),通過指定 socketpair 的第一個(gè)參數(shù)為 AF_UNIX,表示創(chuàng)建匿名 UNIX 域套接字(UNIX domain socket),這樣就可以使用系統(tǒng)函數(shù) sendmsg 和 recvmsg 來傳遞/接收文件描述符了。
主進(jìn)程在調(diào)用 cluster.fork 時(shí),相關(guān)流程如下:
創(chuàng)建 Pipe(pipe_wrap.cc)對(duì)象,并且指定參數(shù) ipc 為 true;
調(diào)用 uv_spawn,options 參數(shù)為 uv_process_options_s 結(jié)構(gòu)體,把 Pipe 對(duì)象存儲(chǔ)在結(jié)構(gòu)體的屬性 stdio 中;
調(diào)用 uv__process_init_stdio,通過 socketpair 創(chuàng)建全雙工 socket;
調(diào)用 uv__process_open_stream,設(shè)置 Pipe 對(duì)象的 iowatcher.fd 值為全雙工 socket 之一。
至此,主從進(jìn)程就可以進(jìn)行雙向通信了。流程圖如下:
我們?cè)倩乜匆幌颅h(huán)境變量 NODE_CHANNEL_FD,令人疑惑的是,它的值始終為3。進(jìn)程級(jí)文件描述符表中,0-2分別是標(biāo)準(zhǔn)輸入stdin、標(biāo)準(zhǔn)輸出stdout和標(biāo)準(zhǔn)錯(cuò)誤輸出stderr,那么可用的第一個(gè)文件描述符就是3,socketpair 顯然會(huì)占用從進(jìn)程的第一個(gè)可用文件描述符。這樣,當(dāng)從進(jìn)程往 fd=3 的流中寫入數(shù)據(jù)時(shí),主進(jìn)程就可以收到消息;反之,亦類似。
從 IPC 讀取消息主要是流操作,以后有機(jī)會(huì)詳解,下面列出主要流程:
StreamBase::EditData 回調(diào) onread;
StreamWrap::OnReadImpl 調(diào)用 StreamWrap::EditData;
StreamWrap 的構(gòu)造函數(shù)會(huì)調(diào)用 set_read_cb 設(shè)置 OnReadImpl;
StreamWrap::set_read_cb 設(shè)置屬性 StreamWrap::read_cb_;
StreamWrap::OnRead 中引用屬性 read_cb_;
StreamWrap::ReadStart 調(diào)用 uv_read_start 時(shí)傳遞 Streamwrap::OnRead 作為第3個(gè)參數(shù):
int uv_read_start(uv_stream_t* stream, uv_alloc_cb alloc_cb, uv_read_cb read_cb)
涉及到的類圖關(guān)系如下:
服務(wù)器主從模型以上大概分析了從進(jìn)程的創(chuàng)建過程及其特殊性;如果要實(shí)現(xiàn)主從服務(wù)模型的話,還需要解決一個(gè)基本問題:從進(jìn)程怎么獲取到與客戶端間的連接描述符?我們打算從 process.send(只有在從進(jìn)程的全局 process 對(duì)象上才有 send 方法,主進(jìn)程可以通過 worker.process 或 worker 訪問該方法)的函數(shù)簽名著手:
void send(message, sendHandle, callback)
其參數(shù) message 和 callback 含義也許顯而易見,分別指待發(fā)送的消息對(duì)象和操作結(jié)束之后的回調(diào)函數(shù)。那它的第二個(gè)參數(shù) sendHandle 用途是什么?
前文提到系統(tǒng)函數(shù) socketpair 可以創(chuàng)建一對(duì)雙向 socket,能夠用來發(fā)送 JSON 消息,這一塊主要涉及到流操作;另外,當(dāng) sendHandle 有值時(shí),它們還可以用于傳遞文件描述符,其過程要相對(duì)復(fù)雜一些,但是最終會(huì)調(diào)用系統(tǒng)函數(shù) sendmsg 以及 recvmsg。
傳遞與客戶端的連接描述符在主從服務(wù)模型下,主進(jìn)程負(fù)責(zé)跟客戶端建立連接,然后把連接描述符通過 sendmsg 傳遞給從進(jìn)程。我們來看看這一過程:
從進(jìn)程
調(diào)用 http.Server.listen 方法(繼承至 net.Server);
調(diào)用 cluster._getServer,向主進(jìn)程發(fā)起消息:
{ "cmd": "NODE_HANDLE", "msg": { "act": "queryServer" } }
主進(jìn)程
接收處理這個(gè)消息時(shí),會(huì)新建一個(gè) RoundRobinHandle 對(duì)象,為變量 handle。每個(gè) handle 與一個(gè)連接端點(diǎn)對(duì)應(yīng),并且對(duì)應(yīng)多個(gè)從進(jìn)程實(shí)例;同時(shí),它會(huì)開啟與連接端點(diǎn)相應(yīng)的 TCP 服務(wù) socket。
class RoundRobinHandle { construtor(key, address, port, addressType, fd) { // 監(jiān)聽同一端點(diǎn)的從進(jìn)程集合 this.all = []; // 可用的從進(jìn)程集合 this.free = []; // 當(dāng)前等待處理的客戶端連接描述符集合 this.handles = []; // 指定端點(diǎn)的TCP服務(wù)socket this.server = null; } add(worker, send) { // 把從進(jìn)程實(shí)例加入this.all } remove(worker) { // 移除指定從進(jìn)程 } distribute(err, handle) { // 把連接描述符handle存入this.handles,并指派一個(gè)可用的從進(jìn)程實(shí)例開始處理連接請(qǐng)求 } handoff(worker) { // 從this.handles中取出一個(gè)待處理的連接描述符,并向從進(jìn)程發(fā)起消息 // { // "type": "NODE_HANDLE", // "msg": { // "act": "newconn", // } // } } }
調(diào)用 handle.add 方法,把 worker 對(duì)象添加到 handle.all 集合中;
當(dāng) handle.server 開始監(jiān)聽客戶端請(qǐng)求之后,重置其 onconnection 回調(diào)函數(shù)為 RoundRobinHandle.distribute,這樣的話主進(jìn)程就不用實(shí)際處理客戶端連接,只要分發(fā)連接給從進(jìn)程處理即可。它會(huì)把連接描述符存入 handle.handles 集合,當(dāng)有可用 worker 時(shí),則向其發(fā)送消息 { "act": "newconn" }。如果被指派的 worker 沒有回復(fù)確認(rèn)消息 { "ack": message.seq, accepted: true },則會(huì)嘗試把該連接分配給其他 worker。
流程圖如下:
從進(jìn)程上調(diào)用listen
客戶端連接處理
從進(jìn)程如何與主進(jìn)程監(jiān)聽同一端口?原因主要有兩點(diǎn):
I. 從進(jìn)程中 Node.js 運(yùn)行時(shí)的初始化略有不同
因?yàn)閺倪M(jìn)程存在環(huán)境變量 NODE_UNIQUE_ID,所以在 bootstrap_node.js 中,加載 cluster 模塊時(shí)執(zhí)行 workerInit 方法。這個(gè)地方與主進(jìn)程執(zhí)行的 masterInit 方法不同點(diǎn)在于:其一,從進(jìn)程上沒有 cluster.fork 方法,所以不能在從進(jìn)程繼續(xù)創(chuàng)建子孫進(jìn)程;其二,Worker 對(duì)象上的方法 disconnect 和 destroy 實(shí)現(xiàn)也有所差異:我們以調(diào)用 worker.destroy 為例,在主進(jìn)程上時(shí),不能直接把從進(jìn)程殺掉,而是通知從進(jìn)程退出,然后再把它從集合里刪除;當(dāng)在從進(jìn)程上時(shí),從進(jìn)程通知完主進(jìn)程然后退出就可以了;其三,從進(jìn)程上 cluster 模塊新增了方法 _getServer,用于向主進(jìn)程發(fā)起消息 {"act": "queryServer"},通知主進(jìn)程創(chuàng)建 RoundRobinHandle 對(duì)象,并實(shí)際監(jiān)聽指定端口地址;然后自身用一個(gè)模擬的 TCP 描述符繼續(xù)執(zhí)行;
調(diào)用 cluster._setupWorker 方法,主要是初始化 cluster.worker 屬性,并監(jiān)聽消息 internalMessage,處理兩種消息類型:newconn 和 disconnect;
向主進(jìn)程發(fā)起消息 { "act": "online" };
因?yàn)閺倪M(jìn)程額環(huán)境變量中有 NODE_CHANNEL_FD,調(diào)用 internalprocess.setupChannel時(shí),會(huì)連接到系統(tǒng)函數(shù) socketpair 創(chuàng)建的雙向 socket ,并監(jiān)聽 internalMessage ,處理消息類型:NODE_HANDLE_ACK和NODE_HANDLE。
II. listen 方法在主從進(jìn)程中執(zhí)行的代碼略有不同。
在 net.Server(net.js)的方法 listen 中,如果是主進(jìn)程,則執(zhí)行標(biāo)準(zhǔn)的端口綁定流程;如果是從進(jìn)程,則會(huì)調(diào)用 cluster._getServer,參見上面對(duì)該方法的描述。
最后,附上基于libuv實(shí)現(xiàn)的一個(gè) C 版 Master-Slave 服務(wù)模型,GitHub地址。
啟動(dòng)服務(wù)器之后,訪問 http://localhost:3333 的運(yùn)行結(jié)果如下:
相信通過本篇文章的介紹,大家已經(jīng)對(duì)Node.js的Cluster有了一個(gè)全面的了解。下一次作者會(huì)跟大家一起深入分析Node.js進(jìn)程管理在生產(chǎn)環(huán)境下的可用性問題,敬請(qǐng)期待。
相關(guān)文章系列1|走進(jìn)Node.js之啟動(dòng)過程剖析
系列2|走進(jìn)Node.js 之 HTTP實(shí)現(xiàn)分析
推薦: 翻譯項(xiàng)目Master的自述: 1. 干貨|人人都是翻譯項(xiàng)目的Master 2. iKcamp出品微信小程序教學(xué)共5章16小節(jié)匯總(含視頻) 3. 開始免費(fèi)連載啦~每周2更共11堂iKcamp課|基于Koa2搭建Node.js實(shí)戰(zhàn)項(xiàng)目教學(xué)(含視頻)| 課程大綱介紹文章版權(quán)歸作者所有,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請(qǐng)注明本文地址:http://specialneedsforspecialkids.com/yun/107020.html
摘要:事實(shí)上,協(xié)議確實(shí)是基于協(xié)議實(shí)現(xiàn)的。的可選參數(shù)用于監(jiān)聽事件另外,它也監(jiān)聽事件,只不過回調(diào)函數(shù)是自己實(shí)現(xiàn)的。并且會(huì)把本次連接的套接字文件描述符封裝成對(duì)象,作為事件的參數(shù)。過載保護(hù)理論上,允許的同時(shí)連接數(shù)只與進(jìn)程可以打開的文件描述符上限有關(guān)。 作者:正龍(滬江Web前端開發(fā)工程師)本文為原創(chuàng)文章,轉(zhuǎn)載請(qǐng)注明作者及出處 上文走進(jìn)Node.js啟動(dòng)過程中我們算是成功入門了。既然Node.js的強(qiáng)...
摘要:具體調(diào)用鏈路如圖函數(shù)主要是解析啟動(dòng)參數(shù),并過濾選項(xiàng)傳給引擎。查閱文檔之后發(fā)現(xiàn),通過指定參數(shù)可以設(shè)置線程池大小。原來的字節(jié)碼編譯優(yōu)化還有都是通過多線程完成又繼續(xù)深入調(diào)查,發(fā)現(xiàn)環(huán)境變量會(huì)影響的線程池大小。執(zhí)行過程如下調(diào)用執(zhí)行。 作者:正龍 (滬江Web前端開發(fā)工程師)本文原創(chuàng),轉(zhuǎn)載請(qǐng)注明作者及出處。 隨著Node.js的普及,越來越多的開發(fā)者使用Node.js來搭建環(huán)境,也有很多公司開始把...
摘要:具體調(diào)用鏈路如圖函數(shù)主要是解析啟動(dòng)參數(shù),并過濾選項(xiàng)傳給引擎。查閱文檔之后發(fā)現(xiàn),通過指定參數(shù)可以設(shè)置線程池大小。原來的字節(jié)碼編譯優(yōu)化還有都是通過多線程完成又繼續(xù)深入調(diào)查,發(fā)現(xiàn)環(huán)境變量會(huì)影響的線程池大小。執(zhí)行過程如下調(diào)用執(zhí)行。 作者:正龍 (滬江Web前端開發(fā)工程師)本文原創(chuàng),轉(zhuǎn)載請(qǐng)注明作者及出處。 隨著Node.js的普及,越來越多的開發(fā)者使用Node.js來搭建環(huán)境,也有很多公司開始把...
摘要:熱門文章我在淘寶做前端的這三年紅了櫻桃,綠了芭蕉。文章將在淘寶的三年時(shí)光折射為入職職業(yè)規(guī)劃招聘晉升離職等與我們息息相關(guān)的經(jīng)驗(yàn)分享,值得品讀。 showImg(https://segmentfault.com/img/remote/1460000018739018?w=1790&h=886); 【Alibaba-TXD 前端小報(bào)】- 熱門前端技術(shù)快報(bào),聚焦業(yè)界新視界;不知不覺 2019 ...
摘要:熱門文章我在淘寶做前端的這三年紅了櫻桃,綠了芭蕉。文章將在淘寶的三年時(shí)光折射為入職職業(yè)規(guī)劃招聘晉升離職等與我們息息相關(guān)的經(jīng)驗(yàn)分享,值得品讀。 showImg(https://segmentfault.com/img/remote/1460000018739018?w=1790&h=886); 【Alibaba-TXD 前端小報(bào)】- 熱門前端技術(shù)快報(bào),聚焦業(yè)界新視界;不知不覺 2019 ...
閱讀 2742·2021-11-24 10:23
閱讀 1153·2021-11-17 09:33
閱讀 2503·2021-09-28 09:41
閱讀 1409·2021-09-22 15:55
閱讀 3641·2019-08-29 16:32
閱讀 1903·2019-08-29 16:25
閱讀 1056·2019-08-29 11:06
閱讀 3421·2019-08-29 10:55