淺析 Node 程式與執行緒

政採雲前端團隊發表於2019-12-29

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

本文首發於政採雲前端團隊部落格: 淺析 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 的程式結構圖:

圖片

為了驗證上述分析,我們執行示例二的程式碼,加入檔案 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,這裡做一下簡要介紹。

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

推薦閱讀

相關文章