前言
本篇是略讀「Multithreaded JavaScript」這本書第六章:「多執行緒實作模式」(Multithreaded Patterns),整理的一些筆記
本章節介紹了一些多執行緒常見的實作模式,有以下:
- 執行緒池(Thread Pool)
- 互斥鎖(Mutex)
- 環形緩衝(Ring Buffers)
- 演員模型(Actor Model)
執行緒池(Thread Pool)
- 多執行緒應用程式很常使用的一個實作方式
- 執行緒池是一個集合,裡面含有同質性(homogeneous)的 worker 執行緒,每個 worker 都可用來處理重負載、複雜運算的工作
- Node.js 的
libuv
函式庫提供了執行緒池的功能,預設為四個執行緒,可以處理底層 I/O 的一些操作 - 概念很類似分散式系統
- 分為兩部分討論:執行緒池的大小(Pool Size)與委派策略(Dispatch Strategies)
執行緒池的大小(Pool Size)
- 一般而言不會動態改變執行緒池的大小
- 通常在作業系統中,處理器核心跟執行緒並沒有直接的關聯
- 當執行緒的數量遠超過處理器核心數量時,效能反而會下降
- 以 Node.js 的
libuv
函式庫為例,裡面包含三個執行緒,分別是:主執行緒、worker 執行緒、垃圾回收執行緒(Garbage Collection Thread)
查看核心數量
// browser
cores = navigator.hardwareConcurrency;
// Node.js
cores = require("os").cpus().length;
決定執行緒池的大小
- 別忘了還有主執行緒,所以是 n + 1
- 依據不同的目的來決定執行緒的數量
- 對於挖礦而言,99.9% 的工作在於 worker 上所執行繁重的運算,幾乎沒有 I/O,主執行緒也沒有特別的事情,因此可以開與核心數量相同的 worker 執行緒
- 對串流影音或轉檔而言,則有大量的 CPU 運算及 I/O 操作,因此必須預留兩個核心給這兩個程序,剩下再分配給 worker 執行緒
- 如果不確定要如何分配,那麼留一個核心給主執行緒是一個安全的作法
委派策略(Dispatch Strategies)
我們將高成本、繁重的運算收集整理起來,然後分配個 worker 執行緒去執行這些任務,這裡介紹三個常見的委派策略:
輪詢調度(Round Robin)
- 按照順序指派工作,指派到最後一位,下一次就回到第一位 worker 身上
- 可以確保每位 worker 都有事做
- 可能造成每位 worker 的負擔不均
- HAProxy 稱此委派策略為 roundrobin
隨機調度(Random)
- 就如字面的意思,隨機選取一個 worker 來處理工作
- 可能造成每位 worker 的負擔不均
閒置優先調度(Least Busy)
- 將新的任務指派給負載最低的 worker
- 當有最低負載的 worker 有兩個的時候,隨機選取一個指派
- HAProxy 稱此委派策略為 leastconn
互斥鎖(Mutex)
- Mutex 全名為 mutually exclusive lock.
- 互斥鎖是一個存取共享資料的控制機制
- 此機制確保共享資料在同一時間,只允許一項任務執行
- 互斥鎖在有人存取共享資料的時候上鎖,並在結束後解鎖
- 在上鎖及釋放鎖定之間,稱之為「臨界區段」(Critical Section)
串流資料與環形緩衝(Ring Buffers)
- 環形緩衝是「先進先出」(first-in-first-out, aka FIFO)佇列的典型實作,利用一組「配對指標」指向當下的記憶體位址
- 當佇列的指標在陣列末位,下一步會移動到陣列首位,形成環形結構的概念
- 在北美餐廳中常被使用的點餐圓盤(order wheel)則是類比世界中,同樣的概念實踐
構成要素
- head 指標:指向下一個寫入佇列的位置
- tail 指標:指向下一個讀取佇列的位置
- 佇列長度:我們想要建立多大的佇列,一個陣列長度,head 指標、tail 指標會在上面移動
運作規則
借用一下書中的示意圖:
- 當寫入佇列時,head 指標移動的下一個位置
- 當讀取佇列時,tail 指標移動的下一個位置
- 當指標位在佇列末位,下一次就會移到首位
- 因為是環形,所以沒有頭尾之分,因此指標在哪個位置並不重要
- tail 指標最多只會跟 head 指標在同一個位置,不能超過 head 指標
- 緩衝區滿載的時候,若要再寫入佇列,則有兩種策略:
- 覆蓋最舊的佇列:相較於未處理的舊資料,新資料比較重要
- 拋出異常,且不寫入佇列:資料順序性很重要的時候
- 必須正確讀出最舊佇列
FIFO、LIFO、動態佇列
- 儲存在環形緩衝裡的每個元素不會做移動,只有加入與移除,適合實作「先進先出」(first-in-first-out, aka FIFO)佇列
- 「非環形緩衝」若要實作 FIFO,則必須在處理完一個任務之後,移動佇列上所有的元素
- 「非環形緩衝」較適合實作「後進先出」(last-in-first-out, aka LIFO)佇列
- JavaScript 的堆疊(stack)就是 LIFO 的實作
- 動態改變佇列的大小,意味著必須重新賦予記憶體,會影響效能。若要實作動態佇列,鍊表(linked list)則較為適合
演員模型(Actor Model)
- 演員模型是實踐同步運算的一種程式設計模式
- 一個 actor 代表一個執行程式碼的容器
- 在 Erlang 程式語言中,actor 是一級公民,但在 JavaScript 中也可以模擬其實作
- 每個 actor 皆具有三種功能:執行運算、建立新的 actor、actor 之間的相互傳遞訊息
- 每個 actor 擁有自己的訊息佇列(message queue),以 FIFO 佇列順序處理每個任務
- 因為 actor 都無法操作共享記憶體(shared memory),因此避免了多執行緒容易發生的情況:race condition 及 deadlock
- actor 是單執行緒,一次執行一件事
- 使用 actor 的系統必須能夠接受延遲或順序不一致的現象
- 每個 actor 可以擁有一個位址,例如:
tcp://127.0.0.1:1234/3
代表著在 1234 port 中,第三個 actor
借用一下書中的示意圖: