系列3|走進Node.js之多進程模型

文:正龍(滬江網(wǎng)校Web前端工程師)

本文原創(chuàng),轉(zhuǎn)載請注明作者及出處

之前的文章“走進Node.js之HTTP實現(xiàn)分析”中土陪,大家已經(jīng)了解 Node.js 是如何處理 HTTP 請求的,在整個處理過程,它僅僅用到單進程模型赂蠢。那么如何讓 Web 應(yīng)用擴展到多進程模型,以便充分利用CPU資源呢纳猫?答案就是 Cluster。本篇文章將帶著大家一起分析Node.js的多進程模型竹捉。

首先芜辕,來一段經(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);
}

通常,主從模型包含一個主進程(master)和多個從進程(worker)块差,主進程負責(zé)接收連接請求侵续,以及把單個的請求任務(wù)分發(fā)給從進程處理;從進程的職責(zé)就是不斷響應(yīng)客戶端請求憨闰,直至進入等待狀態(tài)状蜗。如圖 3-1 所示:

1.PNG

圍繞這段代碼,本文希望講述清楚幾個關(guān)鍵問題:

  1. 從進程的創(chuàng)建過程鹉动;

  2. 在使用同一主機地址的前提下轧坎,如果指定端口已經(jīng)被監(jiān)聽,其它進程嘗試監(jiān)聽同一端口時本應(yīng)該會報錯(EADDRINUSE泽示,即端口已被占用)缸血;那么,Node.js 如何能夠在主從進程上對同一端口執(zhí)行 listen 方法械筛?

進程 fork 是如何完成的捎泻?

在 Node.js 中,cluster.fork 與 POSIX 的 fork 略有不同:雖然從進程仍舊是 fork 創(chuàng)建埋哟,但是并不會直接使用主進程的進程映像笆豁,而是調(diào)用系統(tǒng)函數(shù) execvp 讓從進程使用新的進程映像。另外赤赊,每個從進程對應(yīng)一個 Worker 對象闯狱,它有如下狀態(tài):none、online砍鸠、listening扩氢、dead和disconnected耕驰。

ChildProcess 對象主要提供進程的創(chuàng)建(spawn)爷辱、銷毀(kill)以及進程句柄引用計數(shù)管理(ref 與 unref)。在對Process對象(process_wrap.cc)進行封裝之外,它自身也處理了一些細節(jié)問題饭弓。例如双饥,在方法 spawn 中,如果需要主從進程之間建立 IPC 管道弟断,則通過環(huán)境變量 NODE_CHANNEL_FD 來告知從進程應(yīng)該綁定的 IPC 相關(guān)的文件描述符(fd)咏花,這個特殊的環(huán)境變量后面會被再次涉及到。

以上提到的三個對象引用關(guān)系如下:

4.png

cluster.fork 的主要執(zhí)行流程:

  1. 調(diào)用 child_process.spawn阀趴;

  2. 創(chuàng)建 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);
}
  1. 調(diào)用 ChildProcess._handle 的方法 spawn,并會最終調(diào)用 libuv 庫中 uv_spawn叔汁。

主進程在執(zhí)行 cluster.fork 時统求,會指定兩個特殊的環(huán)境變量 NODE_CHANNEL_FD 和 NODE_UNIQUE_ID,所以從進程的初始化過程跟一般 Node.js 進程略有不同:

  1. bootstrap_node.js 是運行時包含的 JavaScript 入口文件据块,其中調(diào)用 internal\process.setupChannel码邻;

  2. 如果環(huán)境變量包含 NODE_CHANNEL_FD,則調(diào)用 child_process._forkChild另假,然后移除該值像屋;

  3. 調(diào)用 internal\child_process.setupChannel,在子進程的全局 process 對象上監(jiān)聽消息 internalMessage边篮,并且添加方法 send 和 _send开睡。其中 send 只是對 _send 的封裝;通常苟耻,_send 只是把消息 JSON 序列化之后寫入管道篇恒,并最終投遞到接收端。

  4. 如果環(huán)境變量包含 NODE_UNIQUE_ID凶杖,則當(dāng)前進程是 worker 模式胁艰,加載 cluster 模塊時會執(zhí)行 workerInit;另外智蝠,它也會影響到 net.Server 的 listen 方法腾么,worker 模式下 listen 方法會調(diào)用 cluster._getServer,該方法實質(zhì)上向主進程發(fā)起消息 {"act" : "queryServer"}杈湾,而不是真正監(jiān)聽端口解虱。

IPC實現(xiàn)細節(jié)

上文提到了 Node.js 主從進程僅僅通過 IPC 維持聯(lián)絡(luò),那這一節(jié)就來深入分析下 IPC 的實現(xiàn)細節(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

運行結(jié)果如下:

ipc-demo.png

細心的同學(xué)可能發(fā)現(xiàn)控制臺輸出并不是連續(xù)的于宙,master和slave的日志交錯打印,這是由于并行進程執(zhí)行順序不可預(yù)知造成的悍汛。

socketpair

前文提到從進程實際上通過系統(tǒng)調(diào)用 execvp 啟動新的 Node.js 實例捞魁;也就是說默認(rèn)情況下,Node.js 主從進程不會共享文件描述符表离咐,那它們到底是如何互發(fā)消息的呢谱俭?

原來,可以利用 socketpair 創(chuàng)建一對全雙工匿名 socket宵蛀,用于在進程間互發(fā)消息昆著;其函數(shù)簽名如下:

int socketpair(int domain, int type, int protocol, int sv[2]);

通常情況下,我們是無法通過 socket 來傳遞文件描述符的术陶;當(dāng)主進程與客戶端建立了連接宣吱,需要把連接描述符告知從進程處理,怎么辦瞳别?其實征候,通過指定 socketpair 的第一個參數(shù)為 AF_UNIX,表示創(chuàng)建匿名 UNIX 域套接字(UNIX domain socket)祟敛,這樣就可以使用系統(tǒng)函數(shù) sendmsgrecvmsg 來傳遞/接收文件描述符了疤坝。

主進程在調(diào)用 cluster.fork 時,相關(guān)流程如下:

  1. 創(chuàng)建 Pipe(pipe_wrap.cc)對象馆铁,并且指定參數(shù) ipc 為 true跑揉;
  2. 調(diào)用 uv_spawn,options 參數(shù)為 uv_process_options_s 結(jié)構(gòu)體埠巨,把 Pipe 對象存儲在結(jié)構(gòu)體的屬性 stdio 中历谍;
  3. 調(diào)用 uv__process_init_stdio,通過 socketpair 創(chuàng)建全雙工 socket辣垒;
  4. 調(diào)用 uv__process_open_stream望侈,設(shè)置 Pipe 對象的 iowatcher.fd 值為全雙工 socket 之一。

至此勋桶,主從進程就可以進行雙向通信了脱衙。流程圖如下:

5.png

我們再回看一下環(huán)境變量 NODE_CHANNEL_FD,令人疑惑的是例驹,它的值始終為3捐韩。進程級文件描述符表中,0-2分別是標(biāo)準(zhǔn)輸入stdin鹃锈、標(biāo)準(zhǔn)輸出stdout和標(biāo)準(zhǔn)錯誤輸出stderr荤胁,那么可用的第一個文件描述符就是3,socketpair 顯然會占用從進程的第一個可用文件描述符屎债。這樣仅政,當(dāng)從進程往 fd=3 的流中寫入數(shù)據(jù)時垢油,主進程就可以收到消息;反之已旧,亦類似。

6.png

從 IPC 讀取消息主要是流操作召娜,以后有機會詳解运褪,下面列出主要流程:

  1. StreamBase::EditData 回調(diào) onread;

  2. StreamWrap::OnReadImpl 調(diào)用 StreamWrap::EditData玖瘸;

  3. StreamWrap 的構(gòu)造函數(shù)會調(diào)用 set_read_cb 設(shè)置 OnReadImpl秸讹;

  4. StreamWrap::set_read_cb 設(shè)置屬性 StreamWrap::read_cb_;

  5. StreamWrap::OnRead 中引用屬性 read_cb_雅倒;

  6. StreamWrap::ReadStart 調(diào)用 uv_read_start 時傳遞 Streamwrap::OnRead 作為第3個參數(shù):

int uv_read_start(uv_stream_t* stream, uv_alloc_cb alloc_cb, uv_read_cb read_cb)

涉及到的類圖關(guān)系如下:

3.PNG

服務(wù)器主從模型

以上大概分析了從進程的創(chuàng)建過程及其特殊性璃诀;如果要實現(xiàn)主從服務(wù)模型的話,還需要解決一個基本問題:從進程怎么獲取到與客戶端間的連接描述符蔑匣?我們打算從 process.send(只有在從進程的全局 process 對象上才有 send 方法劣欢,主進程可以通過 worker.process 或 worker 訪問該方法)的函數(shù)簽名著手:

void send(message, sendHandle, callback)

其參數(shù) message 和 callback 含義也許顯而易見,分別指待發(fā)送的消息對象和操作結(jié)束之后的回調(diào)函數(shù)裁良。那它的第二個參數(shù) sendHandle 用途是什么凿将?

前文提到系統(tǒng)函數(shù) socketpair 可以創(chuàng)建一對雙向 socket,能夠用來發(fā)送 JSON 消息价脾,這一塊主要涉及到流操作牧抵;另外,當(dāng) sendHandle 有值時侨把,它們還可以用于傳遞文件描述符犀变,其過程要相對復(fù)雜一些,但是最終會調(diào)用系統(tǒng)函數(shù) sendmsg 以及 recvmsg秋柄。

傳遞與客戶端的連接描述符

在主從服務(wù)模型下获枝,主進程負責(zé)跟客戶端建立連接,然后把連接描述符通過 sendmsg 傳遞給從進程骇笔。我們來看看這一過程:

從進程

  1. 調(diào)用 http.Server.listen 方法(繼承至 net.Server)映琳;

  2. 調(diào)用 cluster._getServer,向主進程發(fā)起消息:

{
  "cmd": "NODE_HANDLE",
  "msg": {
    "act": "queryServer"
  }
}

主進程

  1. 接收處理這個消息時蜘拉,會新建一個 RoundRobinHandle 對象萨西,為變量 handle。每個 handle 與一個連接端點對應(yīng)旭旭,并且對應(yīng)多個從進程實例谎脯;同時,它會開啟與連接端點相應(yīng)的 TCP 服務(wù) socket持寄。
class RoundRobinHandle {
  construtor(key, address, port, addressType, fd) {
    // 監(jiān)聽同一端點的從進程集合
    this.all = [];

    // 可用的從進程集合
    this.free = [];

    // 當(dāng)前等待處理的客戶端連接描述符集合
    this.handles = [];

    // 指定端點的TCP服務(wù)socket
    this.server = null;
  }
  add(worker, send) {
    // 把從進程實例加入this.all
  }
  remove(worker) {
    // 移除指定從進程
  }
  distribute(err, handle) {
    // 把連接描述符handle存入this.handles源梭,并指派一個可用的從進程實例開始處理連接請求
  }
  handoff(worker) {
    // 從this.handles中取出一個待處理的連接描述符娱俺,并向從進程發(fā)起消息
    // {
    //  "type": "NODE_HANDLE",
    //  "msg": {
    //    "act": "newconn",
    //  }
    // }
  }
}
  1. 調(diào)用 handle.add 方法,把 worker 對象添加到 handle.all 集合中废麻;

  2. 當(dāng) handle.server 開始監(jiān)聽客戶端請求之后荠卷,重置其 onconnection 回調(diào)函數(shù)為 RoundRobinHandle.distribute,這樣的話主進程就不用實際處理客戶端連接烛愧,只要分發(fā)連接給從進程處理即可油宜。它會把連接描述符存入 handle.handles 集合,當(dāng)有可用 worker 時怜姿,則向其發(fā)送消息 { "act": "newconn" }慎冤。如果被指派的 worker 沒有回復(fù)確認(rèn)消息 { "ack": message.seq, accepted: true },則會嘗試把該連接分配給其他 worker沧卢。

流程圖如下:

從進程上調(diào)用listen

7.png

客戶端連接處理

8.png

從進程如何與主進程監(jiān)聽同一端口蚁堤?

原因主要有兩點:

** I. 從進程中 Node.js 運行時的初始化略有不同**

  1. 因為從進程存在環(huán)境變量 NODE_UNIQUE_ID,所以在 bootstrap_node.js 中但狭,加載 cluster 模塊時執(zhí)行 workerInit 方法披诗。這個地方與主進程執(zhí)行的 masterInit 方法不同點在于:其一,從進程上沒有 cluster.fork 方法立磁,所以不能在從進程繼續(xù)創(chuàng)建子孫進程藤巢;其二,Worker 對象上的方法 disconnect 和 destroy 實現(xiàn)也有所差異:我們以調(diào)用 worker.destroy 為例息罗,在主進程上時掂咒,不能直接把從進程殺掉,而是通知從進程退出迈喉,然后再把它從集合里刪除绍刮;當(dāng)在從進程上時,從進程通知完主進程然后退出就可以了挨摸;其三孩革,從進程上 cluster 模塊新增了方法 _getServer,用于向主進程發(fā)起消息 {"act": "queryServer"}得运,通知主進程創(chuàng)建 RoundRobinHandle 對象膝蜈,并實際監(jiān)聽指定端口地址;然后自身用一個模擬的 TCP 描述符繼續(xù)執(zhí)行熔掺;

  2. 調(diào)用 cluster._setupWorker 方法饱搏,主要是初始化 cluster.worker 屬性,并監(jiān)聽消息 internalMessage置逻,處理兩種消息類型:newconn 和 disconnect推沸;

  3. 向主進程發(fā)起消息 { "act": "online" };

  4. 因為從進程額環(huán)境變量中有 NODE_CHANNEL_FD,調(diào)用 internal\process.setupChannel時鬓催,會連接到系統(tǒng)函數(shù) socketpair 創(chuàng)建的雙向 socket 肺素,并監(jiān)聽 internalMessage ,處理消息類型:NODE_HANDLE_ACK和NODE_HANDLE宇驾。

** II. listen 方法在主從進程中執(zhí)行的代碼略有不同倍靡。**

在 net.Server(net.js)的方法 listen 中,如果是主進程课舍,則執(zhí)行標(biāo)準(zhǔn)的端口綁定流程塌西;如果是從進程,則會調(diào)用 cluster._getServer布卡,參見上面對該方法的描述雨让。

最后雇盖,附上基于libuv實現(xiàn)的一個 C 版 Master-Slave 服務(wù)模型忿等,GitHub地址

啟動服務(wù)器之后崔挖,訪問 http://localhost:3333 的運行結(jié)果如下:

9.png

相信通過本篇文章的介紹贸街,大家已經(jīng)對Node.js的Cluster有了一個全面的了解。下一次作者會跟大家一起深入分析Node.js進程管理在生產(chǎn)環(huán)境下的可用性問題狸相,敬請期待薛匪。

相關(guān)文章

系列1|走進Node.js之啟動過程剖析

系列2|走進Node.js 之 HTTP實現(xiàn)分析

推薦: 翻譯項目Master的自述:

1. 干貨|人人都是翻譯項目的Master

2. iKcamp出品微信小程序教學(xué)共5章16小節(jié)匯總(含視頻)

3. 開始免費連載啦~每周2更共11堂iKcamp課|基于Koa2搭建Node.js實戰(zhàn)項目教學(xué)(含視頻)| 課程大綱介紹

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市脓鹃,隨后出現(xiàn)的幾起案子逸尖,更是在濱河造成了極大的恐慌,老刑警劉巖瘸右,帶你破解...
    沈念sama閱讀 219,490評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件娇跟,死亡現(xiàn)場離奇詭異,居然都是意外死亡太颤,警方通過查閱死者的電腦和手機苞俘,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,581評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來龄章,“玉大人吃谣,你說我怎么就攤上這事∽鋈梗” “怎么了岗憋?”我有些...
    開封第一講書人閱讀 165,830評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長锚贱。 經(jīng)常有香客問我澜驮,道長,這世上最難降的妖魔是什么惋鸥? 我笑而不...
    開封第一講書人閱讀 58,957評論 1 295
  • 正文 為了忘掉前任杂穷,我火速辦了婚禮悍缠,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘耐量。我一直安慰自己飞蚓,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,974評論 6 393
  • 文/花漫 我一把揭開白布廊蜒。 她就那樣靜靜地躺著趴拧,像睡著了一般。 火紅的嫁衣襯著肌膚如雪山叮。 梳的紋絲不亂的頭發(fā)上著榴,一...
    開封第一講書人閱讀 51,754評論 1 307
  • 那天,我揣著相機與錄音屁倔,去河邊找鬼脑又。 笑死,一個胖子當(dāng)著我的面吹牛锐借,可吹牛的內(nèi)容都是我干的问麸。 我是一名探鬼主播,決...
    沈念sama閱讀 40,464評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼钞翔,長吁一口氣:“原來是場噩夢啊……” “哼严卖!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起布轿,我...
    開封第一講書人閱讀 39,357評論 0 276
  • 序言:老撾萬榮一對情侶失蹤哮笆,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后汰扭,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體稠肘,經(jīng)...
    沈念sama閱讀 45,847評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,995評論 3 338
  • 正文 我和宋清朗相戀三年东且,在試婚紗的時候發(fā)現(xiàn)自己被綠了启具。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,137評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡珊泳,死狀恐怖鲁冯,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情色查,我是刑警寧澤薯演,帶...
    沈念sama閱讀 35,819評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站秧了,受9級特大地震影響跨扮,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,482評論 3 331
  • 文/蒙蒙 一衡创、第九天 我趴在偏房一處隱蔽的房頂上張望帝嗡。 院中可真熱鬧,春花似錦璃氢、人聲如沸哟玷。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,023評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽巢寡。三九已至,卻和暖如春椰苟,著一層夾襖步出監(jiān)牢的瞬間抑月,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,149評論 1 272
  • 我被黑心中介騙來泰國打工舆蝴, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留谦絮,地道東北人。 一個月前我還...
    沈念sama閱讀 48,409評論 3 373
  • 正文 我出身青樓须误,卻偏偏與公主長得像挨稿,于是被迫代替她去往敵國和親仇轻。 傳聞我的和親對象是個殘疾皇子京痢,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,086評論 2 355

推薦閱讀更多精彩內(nèi)容

  • https://nodejs.org/api/documentation.html 工具模塊 Assert 測試 ...
    KeKeMars閱讀 6,338評論 0 6
  • # 模塊機制 node采用模塊化結(jié)構(gòu),按照CommonJS規(guī)范定義和使用模塊篷店,模塊與文件是一一對應(yīng)關(guān)系祭椰,即加載一個...
    RichRand閱讀 2,508評論 0 3
  • jedis 3.0.0-SNAPSHOT 版本提供的: 不可用,自己實現(xiàn)代碼:
    安心遠閱讀 522評論 0 0
  • 一護聽聞眉頭緊鎖疲陕,半響未答話方淤,苦苦思索,這是他突然想到眾人幫工這事蹄殃,靈光一線道:“我爹爹在各地都有生意携茂,不如讓大伙...
    源熝閱讀 661評論 0 0