執行緒間劃分工作 (Dividing Work Between Threads)
Overview Table
| 劃分策略 | 前提 | 適用情境 | 代表範例 |
|---|---|---|---|
| 塊狀劃分(處理前劃分) | 資料量事先已知 | 元素可獨立處理、操作相同 | 平行版 std::for_each、parallel_accumulate |
| 遞迴劃分 | 處理過程中才知道如何分 | 遞迴演算法、劃分依賴中間結果 | 平行快速排序(代碼 8.1) |
| 按任務類型劃分 | 工作性質不同 | 資料長度未知、事件驅動、多階段處理 | 管線 (pipeline)、GUI 事件執行緒 |
處理開始前的資料劃分(塊狀劃分)
最簡單的平行化方式:資料量已知時,開始處理前就把資料切成連續區塊,每個執行緒分得一塊,各自獨立處理、互不溝通,最後由主執行緒合併結果(MPI/OpenMP 的典型結構)。
資料: [ e0 e1 e2 e3 | e4 e5 e6 e7 | e8 e9 e10 e11 | e12 ... ]
執行緒 1 執行緒 2 執行緒 3 主執行緒
↓ 獨立處理 ↓ 獨立處理 ↓ 獨立處理 ↓
└───────────────┴──── 主執行緒合併結果 ────┴──────┘
- 平行版
std::for_each:每執行緒對自己的區塊套用相同操作,不需合併結果。 parallel_accumulate(代碼 2.8):各執行緒累加自己的區塊,最後一步合併是串行的;由於累加是遞減 (reduction) 操作,合併步驟本身也可再遞迴平行化。
塊狀劃分要求事先知道資料長度且能整齊切割。若只有處理過資料後才知道怎麼分(如快速排序),或資料是動態產生/外部輸入,此法不適用,需改用遞迴劃分或按任務類型劃分。
遞迴劃分(平行快速排序)
快速排序先以中樞 (pivot) 把資料分成前後兩半,再對兩半遞迴排序——劃分結果依賴前一步的處理,無法事前切塊。但每層遞迴的兩半存取的是不同資料集、完全獨立,天然適合平行:
[ 全部資料 ]
/ \
[ < pivot ] [ >= pivot ] ← 劃分後才知道兩半內容
/ \ / \
[...] [...] [...] [...] ← 每層遞迴皆可併發執行
- 天真作法:每層遞迴用
std::async()產生非同步任務,由標準庫決定何時開新執行緒(第 4 章作法)。 - 問題:大資料集每層都生執行緒,執行緒數量爆炸 → 任務切換氾濫、資源耗盡、效能下降。
代碼 8.1 的改良:限制執行緒數量 + 以執行緒安全堆疊 (thread-safe stack) 分派工作:
// sorter<T> 核心思路(節錄)
thread_safe_stack<chunk_to_sort> chunks; // 待排序資料塊(含 promise)
unsigned const max_thread_count =
std::thread::hardware_concurrency() - 1; // 上限:硬體執行緒數
std::atomic<bool> end_of_data; // 析構時設為 true,結束工作迴圈
// do_sort: 劃分後把「小於 pivot 的一半」推上堆疊,
// 若執行緒未達上限就再開新執行緒;等待結果期間
// 呼叫 try_sort_chunk() 幫忙處理堆疊上的資料塊(不空等)
- 資料塊連同
std::promise推上堆疊;等待 future 就緒時,當前執行緒不空等,改從堆疊取塊來排序 (try_sort_chunk)。 - 工作執行緒在
end_of_data未設定時,反覆從堆疊取工作並std::this_thread::yield()讓出 CPU。 - 析構函數設定
end_of_data並 join 所有執行緒 → 保證清理。 - 這其實是一種特殊的執行緒池 (thread pool) 雛形:所有執行緒從同一個等待清單取任務。
所有執行緒都存取同一個堆疊,重度爭用會降低效能;工作清單的爭用問題與改良(工作竊取等)見第 9 章。此外此版即使資料已不需要排序,閒置執行緒仍會持續嘗試取工作。
按任務類型劃分
前兩種方式中每個執行緒做相同的事、不同的資料;另一種選擇是讓執行緒專職化——各做不同種類的工作(如同水電工與電工各司其職),即關注點分離 (separation of concerns)。
- 每個執行緒只關注自己的職責,狀態由作業系統代為保存與切換,不必在單執行緒主迴圈中手動切分任務、保存進度。
- 典型場景:一個執行緒專門處理使用者事件(按鍵、網路封包),其他執行緒跑長時間任務 → 介面即時回應。
若劃分後執行緒間共享大量資料或頻繁互相等待,代表交互太密切、關注點沒分乾淨。應檢討:交互都指向同一來源 → 抽成單一執行緒負責;兩個執行緒總在密集交流 → 乾脆合併為一個執行緒。
劃分任務序列:管線 (pipeline)
同一組操作序列要套用到多個獨立資料項、且輸入長度未知(網路串流、掃描檔案系統)時,可為每個階段建一個執行緒,資料處理完丟入佇列交給下一階段:
資料流 → [階段1|執行緒A] →佇列→ [階段2|執行緒B] →佇列→ [階段3|執行緒C] → 輸出
資料項 i 在階段2 時,階段1 已可同時處理資料項 i+1
塊狀劃分 vs 管線(20 個資料項、4 核、每項 4 步驟 × 每步 3 秒):
| 方式 | 全部完成時間 | 輸出節奏 |
|---|---|---|
| 塊狀劃分(4 執行緒各處理 5 項) | 60 秒 | 每 12 秒一次湧出 4 項(突發) |
| 管線(4 階段各佔 1 核) | 69 秒(最末核閒置等待 9 秒才開工) | 啟動延遲 12 秒後,每 3 秒穩定產出 1 項 |
管線總時間反而較長(末端階段有啟動延遲),但輸出均勻穩定。高清影片播放需要穩定 ≥25 幀/秒:能「1 秒解 100 幀然後停 1 秒」的程式毫無用處,觀眾卻能接受片頭延遲——管線正好給出穩定解碼率。
Exam/Test Patterns
| 情境/關鍵字 | 答案 |
|---|---|
| 「資料量已知、元素獨立、操作相同」 | 塊狀劃分(處理前切連續區塊,主執行緒合併) |
| 「快速排序如何平行化」 | 遞迴劃分:pivot 劃分後兩半獨立,可併發;每層可用 std::async |
| 「遞迴劃分執行緒數量爆炸」 | 以 hardware_concurrency() 限制上限 + 執行緒安全堆疊存放待處理塊;等待時幫忙取塊處理 |
| 「代碼 8.1 的 sorter 本質是什麼」 | 執行緒池雛形:所有執行緒從同一清單取任務(爭用問題見第 9 章) |
| 「輸入長度未知/資料來自網路」 | 管線 (pipeline) 或按任務類型劃分,不能用塊狀劃分 |
| 「需要穩定輸出節奏(如影片 25fps)」 | 管線:啟動延遲後每階段時間產出一項,輸出平滑 |
| 「執行緒間共享大量資料、頻繁互等」 | 關注點沒分乾淨 → 重新劃分、合併交互密切的執行緒 |
Related Notes
- 08-Designing-Concurrent-Code/02-Factors-Affecting-Performance
- 08-Designing-Concurrent-Code/03-Designing-Data-Structures-for-Performance
- 08-Designing-Concurrent-Code/04-Exception-Safety-and-Scalability
- 08-Designing-Concurrent-Code/05-Parallel-Algorithm-Case-Studies
- 08-Designing-Concurrent-Code/Practice-Designing-Concurrent-Code
- 04-Synchronizing-Operations/02-Futures-and-Asynchronous-Tasks
- 06-Lock-Based-Data-Structures/02-Thread-Safe-Stack-and-Queue
- 09-Advanced-Thread-Management/01-Thread-Pools