淺析Node進程與線程

NO IMAGE

原創不易,希望能關注下我們,再順手點個贊~~

本文首發於政採雲前端團隊博客: 淺析 Node 進程與線程

淺析Node進程與線程

前言

進程與線程是操作系統中兩個重要的角色,它們維繫著不同程序的執行流程,通過系統內核的調度,完成多任務執行。今天我們從 Node.js(以下簡稱 Node)的角度來一起學習相關知識,通過本文讀者將瞭解 Node 進程與線程的特點、代碼層面的使用以及它們之間的通信。

概念

首先,我們還是回顧一下相關的定義:

進程是一個具有一定獨立功能的程序在一個數據集上的一次動態執行的過程,是操作系統進行資源分配和調度的一個獨立單位,是應用程序運行的載體。

線程是程序執行中一個單一的順序控制流,它存在於進程之中,是比進程更小的能獨立運行的基本單位。

早期在單核 CPU 的系統中,為了實現多任務的運行,引入了進程的概念,不同的程序運行在數據與指令相互隔離的進程中,通過時間片輪轉調度執行,由於 CPU 時間片切換與執行很快,所以看上去像是在同一時間運行了多個程序。

由於進程切換時需要保存相關硬件現場、進程控制塊等信息,所以系統開銷較大。為了進一步提高系統吞吐率,在同一進程執行時更充分的利用 CPU 資源,引入了線程的概念。線程是操作系統調度執行的最小單位,它們依附於進程中,共享同一進程中的資源,基本不擁有或者只擁有少量系統資源,切換開銷極小。

單線程?

我們常常聽到有開發者說 “ 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 查看進程的詳細信息,如下所示:

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 的進程結構圖:

淺析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進程與線程

子進程

通過事件循環機制,Node 實現了在 I/O 密集型(I/O-Sensitive)場景下的高併發,但是如果代碼中遇到 CPU 密集場景(CPU-Sensitive)的場景,那麼主線程將長時間阻塞,無法處理額外的請求。為了應對 CPU-Sensitive 場景,以及充分發揮 CPU 多核性能,Node 提供了 child_process 模塊(官方文檔)進行進程的創建、通信、銷燬等等。

創建

child_process 模塊提供了 4 種異步創建 Node 進程的方法,具體可參考 child_process API,這裡做一下簡要介紹。

  • spawn 以主命令加參數數組的形式創建一個子進程,子進程以流的形式返回 data 和 error 信息。
  • exec 是對 spawn 的封裝,可直接傳入命令行執行,以 callback 形式返回 error stdout stderr 信息
  • execFile 類似於 exec 函數,但默認不會創建命令行環境,將直接以傳入的文件創建新的進程,性能略微優於 exec
  • fork 是 spawn 的特殊場景,只能用於創建 node 程序的子進程,默認會建立父子進程的 IPC 信道來傳遞消息

通信

在 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);

通信

使用 parentPort 進行父子線程通信

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

使用 MessageChannel 實現線程間通信

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 密集型任務,如數學計算、加解密,進一步提高進程的吞吐率。因篇幅有限,本次分享到此為止,諸多細節期待與大家相互探討,共同鑽研。

推薦閱讀

相關文章

告訴你如何關閉騰訊廣告定向投放

一位18屆前端玩家的年終總結|年度徵文

記錄Computed源碼分析

手把手帶你入門AST抽象語法樹