0. 背景
單線程運行模型
Node.js架構在Chrome V8引擎之上,它的模型與瀏覽器類似,js代碼運行在單個進程的單個線程上。
- 優點:程序狀態是單一的,在沒有多線程的情況下沒有鎖和線程同步問題,操作系統在調度時也因為較少上下文的切換,可以很好地提高CPU的使用率。
- 缺點:如今CPU基本均是多核的,有的服務器往往還有多個CPU。一個Node進程只能利用一個核,這將導致Node應用無法充分利用多核CPU服務器。另外,由于Node執行在單線程上,不適合處理CPU密集型的任務。
1. 服務模型變遷歷史
- 同步 :同步服務模式是一次只為一個請求服務,所有請求都得按次序等待服務。
- 多進程 :每個連接都需要一個進程來服務,相同的狀態將會在內存中存在很多份,造成浪費。
-
多線程 :讓一個線程服務一個請求,線程相對進程的開銷要小許多,并且線程之間可以共享數據,內存浪費的問題可以得到解決,并且利用線程池可以減少創建和銷毀線程的開銷。
apache httpd
,C10k問題
- 事件驅動 :就是異步IO,使用事件機制,用單個線程來服務所有請求,避免了不必要的內存開銷和上下文切換開銷。
2. Node.js 中的進程操作
2.1 創建子進程
-
child_process.spawn()
:適用于返回大量數據,例如圖像處理,二進制數據處理。 -
child_process.exec()
:產生一個shell
并在該shell
中運行命令,stdout
并stderr
在完成時將和傳遞給回調函數。 -
child_process.execFile()
:類似于exec()
,不同之處在于它默認情況下直接生成命令而無需生成新的shell
。 -
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 事件觸發給應用層。
進程間通信示例:
- 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.Server
、net.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_process
和 net
模塊的組合應用。 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線程間發送消息是通過MessageChannel
和MessagePort
進行通訊的,MessageChannel
是一個異步雙向通信通道,包含port1
、port2
兩個MessagePort
類型對象。MessagePort
是一個通信的一個端,通過postMessage()
和on(message)
來發送與接收消息。在創建Worker時會自動創建一個MessageChannel
。
postMessage()
方法可以發送的消息對象將會通過HTML結構化克隆算法 (HTML structured clone algorithm)
序列化,與Json的序列化有較大的區別:
- 可以有循環引用
- 可以包含內置JS類型的實例,例如RegExps,BigInts,Maps,Sets等。
- 可以包含使用ArrayBuffers和SharedArrayBuffers的類型化數組。(實現內存共享)
- 可以包含
WebAssembly.Module
實例。 - 可以包含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 ]
* 參考資源:
- Node.js Document child_process worker_threads
- 《深入前出Node.js》--樸靈
- SO_REUSEADDR和SO_REUSEPORT參數詳解
- MDN -- Web API MessagePort