Node.js 中的多進程與多線程

0. 背景

單線程運行模型
Node.js架構在Chrome V8引擎之上,它的模型與瀏覽器類似,js代碼運行在單個進程的單個線程上。

  • 優點:程序狀態是單一的,在沒有多線程的情況下沒有鎖和線程同步問題,操作系統在調度時也因為較少上下文的切換,可以很好地提高CPU的使用率。
  • 缺點:如今CPU基本均是多核的,有的服務器往往還有多個CPU。一個Node進程只能利用一個核,這將導致Node應用無法充分利用多核CPU服務器。另外,由于Node執行在單線程上,不適合處理CPU密集型的任務。

1. 服務模型變遷歷史

  • 同步 :同步服務模式是一次只為一個請求服務,所有請求都得按次序等待服務。
  • 多進程 :每個連接都需要一個進程來服務,相同的狀態將會在內存中存在很多份,造成浪費。
  • 多線程 :讓一個線程服務一個請求,線程相對進程的開銷要小許多,并且線程之間可以共享數據,內存浪費的問題可以得到解決,并且利用線程池可以減少創建和銷毀線程的開銷。apache httpdC10k問題
  • 事件驅動 :就是異步IO,使用事件機制,用單個線程來服務所有請求,避免了不必要的內存開銷和上下文切換開銷。

2. Node.js 中的進程操作

2.1 創建子進程

  1. child_process.spawn():適用于返回大量數據,例如圖像處理,二進制數據處理。
  2. child_process.exec():產生一個 shell 并在該 shell 中運行命令,stdoutstderr 在完成時將和傳遞給回調函數。
  3. child_process.execFile():類似于 exec(),不同之處在于它默認情況下直接生成命令而無需生成新的 shell
  4. child_process.fork(): 產生一個新的 Node.js 進程,并使用建立的 IPC 通信通道調用指定的模塊,該通道允許在父級和子級之間發送消息。生成的 Node.js 子進程獨立于父進程,擁有自己的內存,并帶有自己的V8實例。由于需要額外的資源分配,因此不建議生成大量 Node.js 子進程。

2.2 進程間通信——IPC

IPC的全稱是Inter-Process Communication,即進程間通信。進程間通信的目的是為了讓不同的進程能夠互相訪問資源并進行協調工作。

Node.js 中使用 fork() 創建子進程時同時創建了一個IPC管道,( 在linix系統中采用 Unix Domain Socket 實現 ) ,IPC管道與網絡socket比較類似,可以雙向通信,可以相互發送數據。不同的是它們在系統內核中就完成了進程間的通信,而不用經過實際的網絡層,非常高效。

在Node中,IPC通道被抽象為 Stream 對象。在調用 send() 發送消息時,會先將消息序列化,然后發送到 IPC 中。接收到的消息時,先反序列化為對象,然后通過message 事件觸發給應用層。

創建子進程和IPC管道

進程間通信示例:

  • master.js
const { fork } = require('child_process');
console.log('master process ', process.pid, ' start');
const child = fork('message-child.js');
child.send({ hello: 'Hello, I am master ' + process.pid });
child.on('message', m => {
  console.log('Master receive message from: ', child.pid, ', message: ', m)
})
  • child.js
console.log('child process ', process.pid, ' start, parent process ', process.ppid);
process.on('message', m => {
  console.log('Child receive message: ', m);
})
process.send({ hello: 'Hello, I am child: ' + process.pid });
  • 輸出如下:
master process  18547  start
child process  18554  start, parent process  18547
Child receive message:  { hello: 'Hello, I am master 18547' }
Master receive message from:  18554 , message:  { hello: 'Hello, I am child: 18554' }

2.3 共享句柄

在Node.js中,IPC管道發送數據會經過序列化和反序列化,所以無法直接發送對象引用。如果 IPC 管道僅僅只用來發送一些簡單的數據,顯然不夠我們的實際應用使用。

所以在Node.js 中,擴展了IPC的能力,使其可以發送 句柄句柄是一個網絡鏈接的文件描述符,在Node.js 中為net.Servernet.Socket或它們的子類的實例。

通過IPC管道發送HTTP server示例

  • socket-master.js
const { fork } = require('child_process');
const http = require('http');
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('response from master ' + process.pid + '\n');
});
const port = 3000
console.log('master process ', process.pid, ' start');
const child = fork('socket-child.js');
server.listen(port, () => {
  console.log(`server start at ${port}`);
  child.send('server', server);
});
  • socket-child.js
const http = require('http');
console.log('child process ', process.pid, ' start, parent process ', process.ppid);
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('response from child ' + process.pid + '\n');
});
process.on('message', (m, s) => {
  if (m === 'server') {
    console.log('Child receive server socket');
    s.on('connection', (socket) => {
      server.emit('connection', socket);
    })
  }
})
  • 運行結果:
$ node socket-master.js
master process  11687  start
server start at 3000
child process  11694  start, parent process  11687
Child receive server socket
  • 發送HTTP請求的響應結果
$ curl localhost:3000/
response from child 11694
$ curl localhost:3000/
response from child 11694
$ curl localhost:3000/
response from master 11687

通過以上示例,可以看到在master進程中創建了一個HttpServer,然后將這個Server發送給child進程,child進程接收到后也監聽這個Server,如下圖所示。

主進程將句柄發送給子進程

此時master進程和child進程同時在監聽3000端口,都可以處理客戶端發起的請求,請求可能是被父進程處理,也可能被子進程處理。

另一個神奇的現象是:如果在master中把Server發送給子進程后,關閉Server,子進程依然可以繼續監聽響應Server的請求。修改socket-master.js如下:

const { fork } = require('child_process');
const http = require('http');
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('response from master ' + process.pid + '\n');
});
const port = 3000
console.log('master process ', process.pid, ' start');
const child1 = fork('socket-child.js');
const child2 = fork('socket-child.js');
const child3 = fork('socket-child.js');
server.listen(port, () => {
  child1.send('server', server);
  child2.send('server', server);
  child3.send('server', server);
  server.close();
});
  • 發送請求結果如下
$ curl localhost:3000/
response from child 14297
$ curl localhost:3000/
response from child 14303
$ curl localhost:3000/
response from child 14303
$ curl localhost:3000/
response from child 14296
$ curl localhost:3000/
response from child 14303

示意圖如下:

主進程發送完句柄并關閉監聽后的結構

2.4 句柄發送過程

上面的示例看起來好像是master進程把Server對象發送了給子進程,但真實情況卻不是這樣。

當調用send()方法發送message和句柄時,發送到IPC管道中的實際上是我們要發送的句柄文件描述符,文件描述符實際上是一個整數值。這個 message 對象和句柄的文件描述符在寫入到IPC管道時會通過 JSON.stringify() 進行序列化。所以最終發送到IPC通道中的信息都是字符串, send() 方法能發送消息和句柄并不意味著它能發送任意對象。

連接了IPC通道的子進程可以讀取到父進程發來的消息,將字符串通過JSON.parse()解析后,如果消息包含文件描述符,則將其還原出一個對應的句柄對象,再觸發 message 事件將消息和句柄傳遞給應用層使用。

句柄的發送與還原示意圖

2.5 端口共同監聽原理

前面示例中多個進程可以監聽到相同的端口而不引起 EADDRINUSE 異常,是因為Node.js在創建socket監聽端口時,指定了SO_REUSEPORT參數,而且在不同進程中都使用相同的文件描述符句柄。

在多個進程監聽相同端口時,端口上的請求,默認使用搶占式策略,會由操作系統內核隨機挑選一個進程,來進行響應。Node.js還支持另外一種調度模式——Round-Robin(輪叫調度),輪叫調度的工作方式是由主進程接受連接,將其依次分發給工作進程。分發的策略是在N個工作進程中,每次選擇第 i = (i + 1) mod n 個進程來發送連接。要使用輪叫調度策略需要使用cluster模塊。

3. Node.js 多進程集群

使用上面的示例代碼,可以實現一個多進程共享監聽端口的Web Server集群。但是使用起來比較麻煩,因此Node.js又提供了一個cluster模塊,專門用來實現多進程集群。

  • cluster 示例
const cluster = require('cluster');
const http = require('http');
const workerCount = 3;
const port = 3000;

if (cluster.isMaster) {
  console.log('master process ', process.pid, ' start');
  for (let i = 0; i < workerCount; i++) {
    cluster.fork();
  }
  cluster.on('exit', (worker, code, signal) => {
    console.log('worker ' + worker.process.pid + ' died');
  });
} else {
  console.log('child process ', process.pid, ' start, parent process ', process.ppid);
  http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('response from child ' + process.pid + '\n');
  }).listen(port, () => console.log('worker [', process.pid, '] start at ', port));
}

事實上 cluster 模塊就是 child_processnet 模塊的組合應用。 cluster 啟動時,它會在內部啟動TCP服務器,在 cluster.fork() 創建子進程時,將這個TCP服務器端socket的文件描述符發送給工作進程。如果進程是通過 cluster.fork() 復制出來的,那么它的環境變量里就存在 NODE_UNIQUE_ID,如果工作進程中存在 listen() 偵聽網絡端口的調用,它將拿到該文件描述符,通過 SO_REUSEPORT 端口重用,從而實現多個子進程共享端口。

4. Node.js多線程

Node.js在10.5之前是沒有多線程支持的,要使用多線程需要使用C++擴展模塊來支持。從v10.5開始,Node.js添加了線程的支持模塊worker_threads,其實現類似于瀏覽器中的。在v10.x中只是試驗版本,需要加--experimental-worker參數才能啟用 。到了v12.xworker_threads模塊成為了穩定版。下面是一個簡單的示例:

#! node --experimental-worker
const { Worker, isMainThread } = require('worker_threads');
let num = 100;
if (isMainThread) {
  num = 200;
  console.log('main thread pid:', process.pid, 'num=' + num);
  new Worker(__filename);
} else {
  console.log('worker thread pid:', process.pid, 'num=' + num);
}
  • 輸出如下
main thread pid: 846 num=200
worker thread pid: 846 num=100

可以看到,主線程和worker線程的PID相同,而且主線程和worker線程不能直接共享變量值。

4.1 線程間通信

Node.js線程間發送消息是通過MessageChannelMessagePort進行通訊的,MessageChannel是一個異步雙向通信通道,包含port1port2兩個MessagePort類型對象。MessagePort是一個通信的一個端,通過postMessage()on(message)來發送與接收消息。在創建Worker時會自動創建一個MessageChannel

postMessage()方法可以發送的消息對象將會通過HTML結構化克隆算法 (HTML structured clone algorithm)序列化,與Json的序列化有較大的區別:

  1. 可以有循環引用
  2. 可以包含內置JS類型的實例,例如RegExps,BigInts,Maps,Sets等。
  3. 可以包含使用ArrayBuffers和SharedArrayBuffers的類型化數組。(實現內存共享)
  4. 可以包含WebAssembly.Module實例。
  5. 可以包含MessagePorts對象。

MessagePort區別于child_process,當前不支持傳送句柄。由于對象克隆使用結構化克隆算法,因此不會保留不可枚舉的屬性,屬性訪問器和對象原型。

  • 進程間發送消息示例
#! node --experimental-worker
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
  console.log('== main thread pid:', process.pid);
  const worker = new Worker(__filename);
  worker.postMessage('hello');
  const sharedUint8Array = new Uint8Array(new SharedArrayBuffer(4));
  worker.postMessage(sharedUint8Array);
  console.log('== parent thread:', sharedUint8Array);
  worker.on('message', (m) => {
    if (m === 'ok') {
      console.log('== parent thread:', sharedUint8Array);
    }
  });
} else {
  console.log('-- worker thread pid:', process.pid);
  parentPort.on('message', (m) => {
    console.log('-- receive message from main thread:', m);
    if (m instanceof Uint8Array) {
      m[0] = 1;
      m[2] = 100;
      parentPort.postMessage('ok');
      console.log('-- changed data:', m)
    }
  })
}
  • 輸出如下:
== main thread pid: 5758
== parent thread: Uint8Array [ 0, 0, 0, 0 ]
-- worker thread pid: 5758
-- receive message from main thread: hello
== parent thread: Uint8Array [ 1, 0, 100, 0 ]
-- receive message from main thread: Uint8Array [ 0, 0, 0, 0 ]
-- changed data: Uint8Array [ 1, 0, 100, 0 ]

* 參考資源:

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,238評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,430評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,134評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,893評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,653評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,136評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,212評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,372評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,888評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,738評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,939評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,482評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,179評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,588評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,829評論 1 283
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,610評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,916評論 2 372

推薦閱讀更多精彩內容