Alex Liang

淺談 concurrency 和 parallelism 以 Node.js為例

之前看到 concurrency 和 parallelism 比較的文章,把相關的概念和 Node.js 如何應用做個整理

Concurrency 與 Parallelism

首先要說明 concurrency 興 parallelism 的不同: concurrency 是指工作在重疊的時間內執行;parallelism 則是工作在完全相同的時間內執行

以下圖為例:

concurrency vs. parallelism

在計算機組織的概念裡,concurrency 相當於 pipeline。把一件任務分成很多階段交給不同的單位處理,同一時間每個單位只處理一個工作。

而 parallelism 則是 CPU 多核心的概念,每個核心可各自處理任務,同一時間有多個核心消化請求。

了解這二個概念後,我們再來看 Javascript 和 Node.js

Javascript with Single Thread

多數人學習 Javascript 都是從瀏覽器或 Node.js 環境開始執行程式,教材都會強調 JS 為單執行緒語言。實務上,不管 JS 用在前端(瀏覽器)或後端(Node.js) 主要處理非同步事件,如滑鼠點擊按鈕,或前端送請求到後端查詢資料。

這裡就帶到 JS 有名的 callback function,可以設定某個事件發生時,程式要如何回應。如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
button.on("click", (event) => {
// Do something when button has been clicked
console.log(`event: ${event}`);
});
```

上面這段程式碼告訢瀏覽器當 button 被點擊時,要做回應並印出 log。

## Node.js Event Loop

進到 Node.js 環節。它是以 [google V8 引擎](https://v8.dev/) 為核心,讓 javascript 撰寫的程式能提供 API service。

API service 主要處理由前端(或其它 service) 打進來的 request,回傳資料和運算結果。

這裡面需要不斷地監聽外界傳進來的 request,收到後要查詢 Cache/database,最後再回傳。

而 Node.js 是如何應付成千上萬的 request 呢? 答案就在 event loop 設計

以下為 Event loop 在 Node.js server 的概念圖

![Event loop in Node.js](https://media.geeksforgeeks.org/wp-content/uploads/20200224050909/nodejs2.png)

Event loop 是一個不斷地處理外界 request 的無窮迴圈。這些 request 被加進 event queue 後依序處理。

圖示的 thread pool 集中 Node.js 對 I/O 和系統操作的 thread (執行緒)。

從這個角度來看,Node.js 還是有使用多執行緒,只不過 developer 在寫 Node.js 程式時,多半是以單執行緒的方式完成工作。

而 Event loop 從 queue 拿取工作後會經過下列步驟:

![Event loop process](https://media.geeksforgeeks.org/wp-content/uploads/20200224062607/phasesofloop-300x240.png)

各步驟的細節可以到 [這篇文章](https://notes.andywu.tw/2020/%E5%AE%8C%E6%95%B4%E5%9C%96%E8%A7%A3node-js%E7%9A%84event-loop%E4%BA%8B%E4%BB%B6%E8%BF%B4%E5%9C%88/) 查看。

Node.js server 收到 request 後便將工作丟到 event queue 裡,隨即處理下一個 request。同時,event loop 經過數個步驟處理非同步和運算的流程。整個分工模式就像是 concurrency vs. parallelism 那張圖所描繪的 concurrency。

這也是為何 Node.js 不適合作運算量大的服務,當 event loop 在處理工作時,需要等運算結果出來才能完成 callback。在正常情況下,這種操作會讓 service 的 response 拉的很長。

## Multi-Thread in Node.js

看完 Node.js 在 concurrency 的實作,假如想在 Node.js 寫 multi-thread 程式,該怎麼做呢?

Node.js 提供二種原生的方式:

1. [Cluster](https://nodejs.org/api/cluster.html) 可產生隔離的 multi-process application
2. [worker_threads](https://nodejs.org/api/worker_threads.html) 適合每個 process 的隔離不是必要且 worker 之間需要資料交換

### Cluster

以下為簡單的範例說明如何使用 cluster 建立 multi-thread 版本的 HTTP server

``` javascript
import cluster from 'node:cluster';
import http from 'node:http';

if (cluster.isPrimary()) {
cluster.fork();
cluster.fork();
cluster.fork();
cluster.fork();
} else {
http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(3000);
}
  • cluster.isPrimary() 用來判斷是否在主程序
  • cluster.fork() 建立 worker
  • 在每個 worker 建立 HTTP server 監聽 port 3000 並回應 hello world

如果細心看會發現每個 worker 都監聽同一個 port,為何這樣寫是可以的呢?

在 Node.js cluster 裡,任何對 listen() 的呼叫會讓 Node.js 去監聽主程序而不是 worker。

主程序預設會採 round-robin 方式分配新的請求,當主程序收到 request 時,會透過 IPC 傳遞給其它 worker。

以上的範例說明如何使用 cluster 建立多程序的程式,但如果需要在不同 worker 間共享資料得使用另一種方式。

worker_threads

要加快運算量大(CPU-intense)的流程時,worker_threads 能解決這個問題。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const {
Worker,
isMainThread,
parentPort
} = require('node:worker_threads');

const MAX_THREAD_COUNT = 4;

if (isMainThread) {
let workingThreadNum = MAX_THREAD_COUNT;
let count = 0;

for (let i = 0; i < MAX_THREAD_COUNT; i++) {
const worker = new Worker(__filename);
worker.on("message", msg => {
if (msg === "done") {
if (--workingThreadNum === 0) {
process.stdout.write(`count: ${count}`);
}
} else {
process.stdout.write(msg.toString() + " ");
count += 1;
}
});
}
} else {
const { hashFunc } = require("some-library");
for (let i = 1; i < 1000000 / MAX_THREAD_COUNT; i++) {
const randomNum = random64();
parentPort.postMessage(hashFunc(randomNum));
}
parentPort.postMessage("done");
}
  • 使用 new Worker 建立一個 worker thread。 __filename 表示目前的檔案
  • 建立4個 worker threads,並在最後一個收到完成任務的 worker 印出總共處理的數目
  • 假如不是 isMainThread,引入某個 hash function 並透過 postMessage 傳送結果
  • parentPort 為 worker 的 MessagePort,提供雙向溝通,藉此和 main thread 交換訊息。

在操作 multi-thread 時,很重要的一點便是共享記億體的保護。這已超出本文主題,留待之後再寫文章補充。

Reference