工作竊取與佇列爭用 (Work Stealing and Queue Contention)
Overview Table
| 概念 | 重點 |
|---|---|
| 全域佇列的爭用問題(9.1.4) | 處理器越多,單一佇列爭用越嚴重;無鎖佇列也躲不掉快取乒乓 (cache ping-pong) |
thread_local 本地佇列(代碼 9.6) |
池內執行緒各有私有 std::queue,無爭用;非池執行緒的 submit 進全域佇列 |
| 本地佇列的缺點 | 任務分配不均:某執行緒本地佇列爆滿,其他執行緒閒置 |
| 工作竊取佇列(代碼 9.7) | std::deque + mutex;擁有者從前端 push/pop(LIFO)、竊取者從後端 steal(FIFO) |
| 可竊取任務的執行緒池(代碼 9.8) | 取任務順序:本地 → 全域 → 竊取他人;從 my_index+1 開始輪流竊取 |
全域佇列的爭用問題(9.1.4)
09-Advanced-Thread-Management/01-Thread-Pools 的池只有一個全域任務佇列:每次 submit() 推任務、每個 worker 取任務,全都碰同一個佇列。
- 處理器數量增加 → 佇列上的爭用 (contention) 加劇 → 效能下降
- 改用無鎖佇列可消除顯式等待,但多核心對同一塊資料的反覆寫入仍造成快取乒乓 (cache ping-pong),大量時間耗在快取列在核心間搬移(參見第 8 章)
- 根本解法:讓資料(任務)靠近使用它的執行緒 —— 每個執行緒一個本地佇列
thread_local 本地任務佇列(代碼 9.6)
thread_safe_queue<function_wrapper> pool_work_queue; // 全域佇列
typedef std::queue<function_wrapper> local_queue_type;
static thread_local std::unique_ptr<local_queue_type>
local_work_queue; // 每執行緒一份
worker_thread()進入主迴圈前先local_work_queue.reset(new local_queue_type)初始化;std::unique_ptr的解構保證執行緒退出時銷毀佇列- 用
std::unique_ptr<>而非直接放物件:非池內執行緒不該平白擁有一個本地佇列(指標為空即代表「不是池內執行緒」) submit():有本地佇列(池內執行緒)→ 推入本地;否則(外部執行緒)→ 推入全域佇列run_pending_task():先查本地佇列(front()+pop())→ 再pool_work_queue.try_pop()→ 都沒有就yield()
本地佇列不需要鎖
本地佇列可以用普通 std::queue<>,因為只有擁有它的執行緒會存取,不存在競爭 —— 這正是消除爭用的關鍵。
新問題:任務分配不均
以快速排序為例:第一個資料塊進了全域佇列,但後續遞迴切出的所有子任務都堆在某一個 worker 的本地佇列,其他執行緒無事可做 —— 違背使用執行緒池的初衷。解法:工作竊取。
工作竊取佇列(代碼 9.7)
讓閒置執行緒能從其他執行緒的本地佇列竊取 (steal) 任務。本地佇列因此必須可被其他執行緒存取,需要適當同步。書中用 mutex 保護的 std::deque<function_wrapper> 證明可行性(無鎖版本超出範圍);預期竊取是少見事件,mutex 爭用因此很低。
void push(data_type data); // 前端 push_front —— 擁有者
bool try_pop(data_type& res); // 前端 pop_front —— 擁有者 (LIFO)
bool try_steal(data_type& res); // 後端 pop_back —— 竊取者 (FIFO)
work_stealing_queue(一個 deque,兩端操作)
┌───────────────────────────────────────┐
擁有者 │ front back │ 竊取者
push() ────►│ [最新任務][ ...較新... ][ 最舊任務 ] │◄──── try_steal()
try_pop()◄──│ (LIFO:剛推入的先做) (FIFO:拿最舊的)│────► 其他執行緒
└───────────────────────────────────────┘
為什麼擁有者取前端、竊取者取後端?
| 設計 | 理由 |
|---|---|
| 擁有者 LIFO(前端) | 快取親和性:最新任務的相關資料還熱在快取中;對快速排序這類遞迴演算法,先處理最新子塊可減少活動任務數量與堆疊使用 |
| 竊取者 FIFO(後端) | 減少與 try_pop() 的競爭:兩者操作 deque 不同端,幾乎不相撞;搭配第 6、7 章技術甚至可讓兩者真正並發執行 |
使用工作竊取的完整執行緒池(代碼 9.8)
std::vector<std::unique_ptr<work_stealing_queue>> queues; // 池持有全部本地佇列
static thread_local work_stealing_queue* local_work_queue; // 指向自己的佇列
static thread_local unsigned my_index; // 自己的序號
- 建構時為每條執行緒建立一個
work_stealing_queue存入queues,並把序號i傳給worker_thread(i);worker 以queues[my_index].get()認領自己的佇列 → 池能存取所有本地佇列,竊取才可能 run_pending_task()依序嘗試三個來源:
run_pending_task()
│ (1) pop_task_from_local_queue() 自己的本地佇列 (前端)
│ (2) pop_task_from_pool_queue() 全域佇列
│ (3) pop_task_from_other_thread_queue() 竊取他人佇列 (後端)
└─ 都失敗 → std::this_thread::yield()
┌──────────────────────────┐
外部 submit ───► │ pool_work_queue(全域) │
└────────────▲─────────────┘
│ (2)
Worker 0 │ Worker 1(閒置)
┌─────────────┐ │ ┌─────────────┐
│ (1) 本地取 │───────────────┘ │ (3) 竊取 │
└──────┬──────┘ └──────┬──────┘
│ front (try_pop) │ back (try_steal)
▼ ▼
┌────────────────────┐ try_steal(從 index+1 輪詢) ┌────────────────────┐
│ queues[0] (deque) │◄───────────────────────────│ queues[1] (deque) │
└────────────────────┘ └────────────────────┘
pop_task_from_other_thread_queue()遍歷所有佇列嘗試try_steal(),從(my_index + i + 1) % queues.size()開始 —— 每條執行緒從「下一個」執行緒開始輪詢,避免大家都先打第一條執行緒的佇列造成熱點
三層取任務順序是效能設計
本地(零爭用、快取熱)→ 全域(低爭用)→ 竊取(最貴、預期少見)。順序反映成本由低到高。
這仍不是終極執行緒池
書中留下的改進方向:動態調整池大小(即使執行緒因 I/O 或互斥鎖阻塞,仍維持 CPU 最佳使用率)、每用途特化的效能調校等。
Exam/Test Patterns
| 情境/關鍵字 | 答案 |
|---|---|
| 「執行緒變多,單一任務佇列變慢」 | 佇列爭用;即使無鎖佇列仍有快取乒乓 → 本地任務佇列 |
「本地佇列為何可用普通 std::queue?」 |
只有擁有者執行緒會存取,無競爭不需鎖 |
「為何本地佇列用 std::unique_ptr + thread_local?」 |
非池內執行緒不該有本地佇列;指標空 ↔ 外部執行緒,submit 落全域佇列 |
| 「本地佇列滿、其他執行緒閒置」 | 任務分配不均 → 工作竊取 (work stealing) |
| 「擁有者從哪端取?竊取者從哪端取?」 | 擁有者前端 (LIFO,快取親和);竊取者後端 (FIFO,減少競爭) |
「run_pending_task() 的取任務順序」 |
本地 → 全域 → 竊取他人,失敗則 yield() |
「竊取時為何從 my_index+1 開始遍歷?」 |
避免所有執行緒都先竊取第一條佇列,分散竊取壓力 |
| 「為什麼書中竊取佇列用 mutex 就夠?」 | 竊取被假設為少見事件,mutex 爭用低、實作簡單(無鎖版超出範圍) |
Related Notes
- 09-Advanced-Thread-Management/01-Thread-Pools
- 09-Advanced-Thread-Management/03-Interrupting-Threads
- 09-Advanced-Thread-Management/Practice-Advanced-Thread-Management
- 08-Designing-Concurrent-Code/02-Factors-Affecting-Performance
- 06-Lock-Based-Data-Structures/02-Thread-Safe-Stack-and-Queue
- 07-Lock-Free-Data-Structures/04-Lock-Free-Queue-and-Memory-Ordering