原創不易,但願能關注下咱們,再順手點個贊~~ |
本文首發於政採雲前端團隊博客: 淺析 Node 進程與線程javascript
進程與線程是操做系統中兩個重要的角色,它們維繫着不一樣程序的執行流程,經過系統內核的調度,完成多任務執行。今天咱們從 Node.js(如下簡稱 Node)的角度來一塊兒學習相關知識,經過本文讀者將瞭解 Node 進程與線程的特色、代碼層面的使用以及它們之間的通訊。css
首先,咱們仍是回顧一下相關的定義:html
進程是一個具備必定獨立功能的程序在一個數據集上的一次動態執行的過程,是操做系統進行資源分配和調度的一個獨立單位,是應用程序運行的載體。前端
線程是程序執行中一個單一的順序控制流,它存在於進程之中,是比進程更小的能獨立運行的基本單位。java
早期在單核 CPU 的系統中,爲了實現多任務的運行,引入了進程的概念,不一樣的程序運行在數據與指令相互隔離的進程中,經過時間片輪轉調度執行,因爲 CPU 時間片切換與執行很快,因此看上去像是在同一時間運行了多個程序。node
因爲進程切換時須要保存相關硬件現場、進程控制塊等信息,因此係統開銷較大。爲了進一步提升系統吞吐率,在同一進程執行時更充分的利用 CPU 資源,引入了線程的概念。線程是操做系統調度執行的最小單位,它們依附於進程中,共享同一進程中的資源,基本不擁有或者只擁有少許系統資源,切換開銷極小。react
咱們經常聽到有開發者說 「 Node.js 是單線程的」,那麼 Node 確實是只有一個線程在運行嗎?算法
首先,在終行如下 Node 代碼(示例一):編程
# 示例一
require('http').createServer((req, res) => {
res.writeHead(200);
res.end('Hello World');
}).listen(8000);
console.log('process id', process.pid);
複製代碼
Node 內建模塊 http 建立了一個監聽 8000 端口的服務,並打印出該服務運行進程的 pid,控制檯輸出 pid 爲 35919(可變),而後咱們經過命令 top -pid 35919
查看進程的詳細信息,以下所示:api
PID COMMAND %CPU TIME #TH #WQ #POR MEM PURG CMPRS PGRP PPID STATE BOOSTS %CPU_ME
35919 node 0.0 00:00.09 7 0 35 8564K 0B 8548K 35919 35622 sleeping *0[1] 0.00000
複製代碼
咱們看到 #TH
(threads 線程) 這一列顯示此進程中包含 7 個線程,說明 Node 進程中並不是只有一個線程。事實上一個 Node 進程一般包含:1 個 Javascript 執行主線程;1 個 watchdog 監控線程用於處理調試信息;1 個 v8 task scheduler 線程用於調度任務優先級,加速延遲敏感任務執行;4 個 v8 線程(可參考如下代碼),主要用來執行代碼調優與 GC 等後臺任務;以及用於異步 I / O 的 libuv 線程池。
// v8 初始化線程
const int thread_pool_size = 4; // 默認 4 個線程
default_platform = v8::platform::CreateDefaultPlatform(thread_pool_size);
V8::InitializePlatform(default_platform);
V8::Initialize();
複製代碼
其中異步 I/O 線程池,若是執行程序中不包含 I/O 操做如文件讀寫等,則默認線程池大小爲 0,不然 Node 會初始化大小爲 4 的異步 I/O 線程池,固然咱們也能夠經過 process.env.UV_THREADPOOL_SIZE
本身設定線程池大小。須要注意的是在 Node 中網絡 I/O 並不佔用線程池。
下圖爲 Node 的進程結構圖:
爲了驗證上述分析,咱們運行示例二的代碼,加入文件 I/O 操做:
# 示例二
require('fs').readFile('./test.log', err => {
if (err) {
console.log(err);
process.exit();
} else {
console.log(Date.now(), 'Read File I/O');
}
});
console.log(process.pid);
複製代碼
而後獲得以下結果:
PID COMMAND %CPU TIME #TH #WQ #POR MEM PURG CMPR PGRP PPID STATE BOOSTS %CPU_ME %CPU_OTHRS
39443 node 0.0 00:00.10 11 0 39 8088K 0B 0B 39443 35622 sleeping *0[1] 0.00000 0.00000
複製代碼
此時 #TH
一欄的線程數變成了 11,即大小爲 4 的 I/O 線程池被建立。至此,咱們針對段首的問題內心有了答案,Node 嚴格意義講並不是只有一個線程,一般說的 「Node 是單線程」 實際上是指 JS 的執行主線程只有一個。
既然 JS 執行線程只有一個,那麼 Node 爲何還能支持較高的併發?
從上文異步 I/O 咱們也能得到一些思路,Node 進程中經過 libuv 實現了一個事件循環機制(uv_event_loop),當執主程發生阻塞事件,如 I/O 操做時,主線程會將耗時的操做放入事件隊列中,而後繼續執行後續程序。
uv_event_loop 嘗試從 libuv 的線程池(uv_thread_pool)中取出一個空閒線程去執行隊列中的操做,執行完畢得到結果後,通知主線程,主線程執行相關回調,而且將線程實例歸還給線程池。經過此模式循環往復,來保證非阻塞 I/O,以及主線程的高效執行。
相關流程可參照下圖:
經過事件循環機制,Node 實現了在 I/O 密集型(I/O-Sensitive)場景下的高併發,可是若是代碼中遇到 CPU 密集場景(CPU-Sensitive)的場景,那麼主線程將長時間阻塞,沒法處理額外的請求。爲了應對 CPU-Sensitive 場景,以及充分發揮 CPU 多核性能,Node 提供了 child_process 模塊(官方文檔)進行進程的建立、通訊、銷燬等等。
child_process 模塊提供了 4 種異步建立 Node 進程的方法,具體可參考 child_process API,這裏作一下簡要介紹。
在 Linux 系統中,能夠經過管道、消息隊列、信號量、共享內存、Socket 等手段來實現進程通訊。在 Node 中,父子進程可經過 IPC(Inter-Process Communication) 信道收發消息,IPC 由 libuv 經過管道 pipe 實現。一旦子進程被建立,並設置父子進程的通訊方式爲 IPC(參考 stdio 設置),父子進程便可雙向通訊。
進程之間經過 process.send
發送消息,經過監聽 message
事件接收消息。當一個進程發送消息時,會先序列化爲字符串,送入 IPC 信道的一端,另外一個進程在另外一端接收消息內容,而且反序列化,所以咱們能夠在進程之間傳遞對象。
如下是 Node.js 建立進程和通訊的一個基礎示例,主進程建立一個子進程並將計算斐波那契數列的第 44 項這一 CPU 密集型的任務交給子進程,子進程執行完成後經過 IPC 信道將結果發送給主進程:
main_process.js
# 主進程
const { fork } = require('child_process');
const child = fork('./fib.js'); // 建立子進程
child.send({ num: 44 }); // 將任務執行數據經過信道發送給子進程
child.on('message', message => {
console.log('receive from child process, calculate result: ', message.data);
child.kill();
});
child.on('exit', () => {
console.log('child process exit');
});
setInterval(() => { // 主進程繼續執行
console.log('continue excute javascript code', new Date().getSeconds());
}, 1000);
複製代碼
fib.js
# 子進程 fib.js
// 接收主進程消息,計算斐波那契數列第 N 項,併發送結果給主進程
// 計算斐波那契數列第 n 項
function fib(num) {
if (num === 0) return 0;
if (num === 1) return 1;
return fib(num - 2) + fib(num - 1);
}
process.on('message', msg => { // 獲取主進程傳遞的計算數據
console.log('child pid', process.pid);
const { num } = msg;
const data = fib(num);
process.send({ data }); // 將計算結果發送主進程
});
// 收到 kill 信息,進程退出
process.on('SIGHUP', function() {
process.exit();
});
複製代碼
結果:
child pid 39974
continue excute javascript code 41
continue excute javascript code 42
continue excute javascript code 43
continue excute javascript code 44
receive from child process, calculate result: 1134903170
child process exit
複製代碼
爲了更加方便的管理進程、負載均衡以及實現端口複用,Node 在 v0.6 以後引入了 cluster 模塊(官方文檔),相對於子進程模塊,cluster 實現了單 master 主控節點和多 worker 執行節點的通用集羣模式。cluster master 節點能夠建立銷燬進程並與子進程通訊,子進程之間不能直接通訊;worker 節點則負責執行耗時的任務。
cluster 模塊同時實現了負載均衡調度算法,在類 unix 系統中,cluster 使用輪轉調度(round-robin),node 中維護一個可用 worker 節點的隊列 free,和一個任務隊列 handles。當一個新的任務到來時,節點隊列隊首節點出隊,處理該任務,並返回確認處理標識,依次調度執行。而在 win 系統中,Node 經過 Shared Handle 來處理負載,經過將文件描述符、端口等信息傳遞給子進程,子進程經過信息建立相應的 SocketHandle / ServerHandle,而後進行相應的端口綁定和監聽,處理請求。
cluster 大大的簡化了多進程模型的使用,如下是使用示例:
# 計算斐波那契數列第 43 / 44 項
const cluster = require('cluster');
// 計算斐波那契數列第 n 項
function fib(num) {
if (num === 0) return 0;
if (num === 1) return 1;
return fib(num - 2) + fib(num - 1);
}
if (cluster.isMaster) { // 主控節點邏輯
for (let i = 43; i < 45; i++) {
const worker = cluster.fork() // 啓動子進程
// 發送任務數據給執行進程,並監聽子進程回傳的消息
worker.send({ num: i });
worker.on('message', message => {
console.log(`receive fib(${message.num}) calculate result ${message.data}`)
worker.kill();
});
}
// 監聽子進程退出的消息,直到子進程所有退出
cluster.on('exit', worker => {
console.log('worker ' + worker.process.pid + ' killed!');
if (Object.keys(cluster.workers).length === 0) {
console.log('calculate main process end');
}
});
} else {
// 子進程執行邏輯
process.on('message', message => { // 監聽主進程發送的信息
const { num } = message;
console.log('child pid', process.pid, 'receive num', num);
const data = fib(num);
process.send({ data, num }); // 將計算結果發送給主進程
})
}
複製代碼
在 Node v10 之後,爲了減少 CPU 密集型任務計算的系統開銷,引入了新的特性:工做線程 worker_threads(官方文檔)。經過 worker_threads 能夠在進程內建立多個線程,主線程與 worker 線程使用 parentPort 通訊,worker 線程之間可經過 MessageChannel 直接通訊。
經過 worker_threads 模塊中的 Worker 類咱們能夠經過傳入執行文件的路徑建立線程。
const { Worker } = require('worker_threads');
...
const worker = new Worker(filepath);
複製代碼
worker_threads 中使用了 MessagePort(繼承於 EventEmitter,參考)來實現線程通訊。worker 線程實例上有 parentPort 屬性,是 MessagePort 類型的一個實例,子線程可利用 postMessage 經過 parentPort 向父線程傳遞數據,示例以下:
const { Worker, isMainThread, parentPort } = require('worker_threads');
// 計算斐波那契數列第 n 項
function fib(num) {
if (num === 0) return 0;
if (num === 1) return 1;
return fib(num - 2) + fib(num - 1);
}
if (isMainThread) { // 主線程執行函數
const worker = new Worker(__filename);
worker.once('message', (message) => {
const { num, result } = message;
console.log(`Fibonacci(${num}) is ${result}`);
process.exit();
});
worker.postMessage(43);
console.log('start calculate Fibonacci');
// 繼續執行後續的計算程序
setInterval(() => {
console.log(`continue execute code ${new Date().getSeconds()}`);
}, 1000);
} else { // 子線程執行函數
parentPort.once('message', (message) => {
const num = message;
const result = fib(num);
// 子線程執行完畢,發消息給父線程
parentPort.postMessage({
num,
result
});
});
}
複製代碼
結果:
start calculate Fibonacci
continue execute code 8
continue execute code 9
continue execute code 10
continue execute code 11
Fibonacci(43) is 433494437
複製代碼
worker_threads 還能夠支持線程間的直接通訊,經過兩個鏈接在一塊兒的 MessagePort 端口,worker_threads 實現了雙向通訊的 MessageChannel。線程間可經過 postMessage 相互通訊,示例以下:
const {
isMainThread, parentPort, threadId, MessageChannel, Worker
} = require('worker_threads');
if (isMainThread) {
const worker1 = new Worker(__filename);
const worker2 = new Worker(__filename);
// 建立通訊信道,包含 port1 / port2 兩個端口
const subChannel = new MessageChannel();
// 兩個子線程綁定各自信道的通訊入口
worker1.postMessage({ port: subChannel.port1 }, [ subChannel.port1 ]);
worker2.postMessage({ port: subChannel.port2 }, [ subChannel.port2 ]);
} else {
parentPort.once('message', value => {
value.port.postMessage(`Hi, I am thread${threadId}`);
value.port.on('message', msg => {
console.log(`thread${threadId} receive: ${msg}`);
});
});
}
複製代碼
結果:
thread2 receive: Hi, I am thread1
thread1 receive: Hi, I am thread2
複製代碼
worker_threads 只適用於進程內部 CPU 計算密集型的場景,而不適合於 I/O 密集場景,針對後者,官方建議使用進程的 event_loop 機制,將會更加高效可靠。
Node.js 自己設計爲單線程執行語言,經過 libuv 的線程池實現了高效的非阻塞異步 I/O,保證語言簡單的特性,儘可能減小編程複雜度。可是也帶來了在多核應用以及 CPU 密集場景下的劣勢,爲了補齊這塊短板,Node 可經過內建模塊 child_process 建立額外的子進程來發揮多核的能力,以及在不阻塞主進程的前提下處理 CPU 密集任務。
爲了簡化開發者使用多進程模型以及端口複用,Node 又提供了 cluster 模塊實現主-從節點模式的進程管理以及負載調度。因爲進程建立、銷燬、切換時系統開銷較大,worker_threads 模塊又隨之推出,在保持輕量的前提下,能夠利用更少的系統資源高效地處理 進程內 CPU 密集型任務,如數學計算、加解密,進一步提升進程的吞吐率。因篇幅有限,本次分享到此爲止,諸多細節期待與你們相互探討,共同鑽研。