異常安全與可擴展性 (Exception Safety and Scalability)
Overview Table
| 主題 | 關鍵點 |
|---|---|
| 平行演算法異常安全 | 新執行緒上的異常無法自動傳回呼叫者;裸 std::thread 版會 std::terminate |
| 逐步改造 | packaged_task/future 轉移異常 → join_threads RAII → std::async 版天然安全 |
| Amdahl 定律 | P = 1 / (fs + (1 - fs) / N);串行部分 fs 決定加速上限 1/fs |
| 隱藏延遲 | 執行緒等待(I/O、鎖)時讓 CPU 跑別的工作:加執行緒、非同步 I/O、自己動手做 |
| 提升響應能力 | GUI 事件迴圈獨立成專用執行緒,長任務丟給任務執行緒 + atomic<bool> 取消旗標 |
平行演算法的異常安全
串行演算法中異常可直接傳給呼叫者處理;平行演算法的操作跑在別的執行緒上,異常傳不過去。若函數帶著新執行緒異常退出,std::thread 解構時仍 joinable → std::terminate 終止程式。
原始版的漏洞(代碼 8.2,裸 std::thread + 結果陣列)
| 拋出點 | 後果 |
|---|---|
主執行緒:產生第一條執行緒後再拋(如建構後續 std::thread 失敗) |
已建的 std::thread 物件解構 → std::terminate |
工作執行緒:accumulate_block 內拋出 |
無 catch → std::terminate |
| 全部 join 之後拋出 | 無害(執行緒已匯合) |
改造一:std::packaged_task + std::future 轉移異常(代碼 8.3)
std::packaged_task<T(Iterator,Iterator)> task(accumulate_block<Iterator,T>());
futures[i] = task.get_future();
threads[i] = std::threadmove(task), block_start, block_end;
// ...join 後:
result += futures[i].get(); // 工作執行緒的異常在這裡重新拋出
- 工作執行緒的異常存進 future,主執行緒
get()時重新拋出 → 工作執行緒的異常問題解決。 - 多條執行緒都拋異常時只有一個會傳到主執行緒;真在乎可用
std::nested_exception收集全部。 - 剩下的洞:主執行緒在「產生執行緒之後、join 之前」拋異常 → 執行緒洩漏。粗解法是 try/catch 裡逐一 join 再重拋,但醜且重複。
改造二:join_threads RAII(代碼 8.4)
class join_threads { // thread_guard 的向量版
std::vector<std::thread>& threads;
public:
explicit join_threadsthread>& t) : threads(t {}
~join_threads() {
for (auto& t : threads)
if (t.joinable()) t.join();
}
};
- 無論正常返回或異常離開,解構函數都保證 join → 不再需要 try/catch。
- 此版不需顯式 join 迴圈:
futures[i].get()本身會阻塞至結果就緒。
改造三:std::async 版——天然異常安全(代碼 8.5)
template <typename Iterator, typename T>
T parallel_accumulate(Iterator first, Iterator last, T init) {
unsigned long const length = std::distance(first, last);
unsigned long const max_chunk_size = 25;
if (length <= max_chunk_size)
return std::accumulate(first, last, init);
Iterator mid_point = first;
std::advance(mid_point, length / 2);
auto first_half = std::async(parallel_accumulate<Iterator, T>,
first, mid_point, init); // 非同步處理前半
T second_half = parallel_accumulate(mid_point, last, T()); // 遞迴處理後半
return first_half.get() + second_half; // 異常在 get() 傳播
}
- 遞迴對半劃分取代事前切塊;標準庫自行調度,充分利用硬體且不會超額申請(部分「非同步」呼叫實為
get()時同步執行)。 - 天然安全的關鍵:遞迴中拋異常時,
std::async回傳的 future 解構會等待對應執行緒結束 → 不會有懸空執行緒;工作端異常存於 future,get()時重新拋出。
異常安全是正確性問題
可擴展性差只是慢,不是錯;異常不安全則會破壞不變量、產生競爭條件,甚至讓程式直接 std::terminate——優先級最高。
可擴展性與 Amdahl 定律
可擴展性 (scalability):核心數增加時,程式能否等比提升效能(縮短單一動作時間,或在同時間內做更多工作)。單執行緒程式完全不可擴展;理想是 100 核 = 100 倍效能。
把程式簡化為「串行部分(只能單執行緒做)+ 並行部分(所有處理器可分工)」,串行部分占比為 fs,處理器數為 N,則效能增益:
1
P = ───────────────────── (Amdahl 定律)
fs + (1 - fs) / N
fs = 0(全可並行)→P = N,線性加速。N → ∞時P → 1/fs:串行占比決定加速上限——fs = 1/3時再多處理器也不會超過 3 倍。- 啟示:最大化並行、縮小串行部分與執行緒等待;或提供更多資料讓並行部分做更多工作(擴大問題規模)。
簡化模型
Amdahl 定律假設 fs 固定、忽略同步/通訊開銷與快取效應;實務上執行緒還會互等、等 I/O,實際增益通常更低。它是「上限估計」而非預測值。
用多執行緒隱藏延遲
執行緒常因等待(I/O、mutex、條件變數)而阻塞——阻塞就是把 CPU 時間讓出來。若系統還有工作可做,就能把等待「隱藏」起來:
- 加開執行緒:病毒掃描器以管線劃分——一條執行緒搜檔案系統丟入佇列,掃描執行緒受 I/O 限制時,多開幾條掃描執行緒填滿 CPU 空檔。注意過多執行緒又會回到任務切換問題,改動前後都要實測。
- 不加執行緒的替代方案:改用非同步 I/O(等待期間做別的事);或等待別的執行緒完成某操作時,乾脆自己動手做(如第 7 章無鎖佇列、代碼 8.1 排序器等待期間幫忙排堆疊上的塊)。
用併發提升響應能力(GUI 事件執行緒分離)
GUI 框架多為事件驅動 (event driven):單執行緒下長任務必須手動切碎、定期回到事件迴圈,否則介面凍結。用關注點分離解決——專用 GUI 執行緒處理事件,長任務交給任務執行緒:
std::atomic<bool> task_cancelled(false);
// GUI 執行緒: while(true){ event=get_event(); if(quit)break; process(event); }
// 任務執行緒: while(!task_complete() && !task_cancelled) do_next_operation();
// 被取消→perform_cleanup();完成→post_gui_event(task_complete);
// process(): start_task→ 開 task_thread;stop_task→ task_cancelled=true 後 join;
// task_complete→ join 後 display_results();
- 取消訊號用
std::atomic<bool>傳遞;任務完成用「回傳事件」通知 GUI 執行緒。 - 使用者事件永遠有人即時處理 → 介面不再卡死;任務程式碼也不必和事件處理攪在一起。
Exam/Test Patterns
| 情境/關鍵字 | 答案 |
|---|---|
| 「工作執行緒拋異常,程式直接掛掉」 | 無人捕捉 → std::terminate;用 packaged_task/promise 把異常存進 future,主執行緒 get() 重拋 |
| 「產生執行緒後主執行緒拋異常」 | joinable 的 std::thread 解構 → terminate;用 join_threads RAII 保證匯合 |
| 「最簡潔的異常安全平行累加」 | std::async 遞迴對半劃分:future 解構等待執行緒、get() 傳播異常,天然安全 |
| 「fs = 串行占比,N 無限大,加速上限?」 | P = 1/(fs+(1-fs)/N) → 上限 1/fs(fs=1/3 → 最多 3 倍) |
| 「執行緒被 I/O 卡住,CPU 閒置」 | 隱藏延遲:加執行緒 / 非同步 I/O / 等待者自己執行任務 |
| 「GUI 跑長任務會凍結」 | 分離關注點:專用事件執行緒 + 任務執行緒;atomic<bool> 取消、事件回傳完成通知 |
| 「可擴展性 vs 異常安全哪個是正確性問題」 | 異常安全;不可擴展只是效能差,程式仍正確 |
Related Notes
- 08-Designing-Concurrent-Code/01-Dividing-Work-Between-Threads
- 08-Designing-Concurrent-Code/02-Factors-Affecting-Performance
- 08-Designing-Concurrent-Code/03-Designing-Data-Structures-for-Performance
- 08-Designing-Concurrent-Code/05-Parallel-Algorithm-Case-Studies
- 08-Designing-Concurrent-Code/Practice-Designing-Concurrent-Code
- 02-Thread-Management/01-Launching-and-Joining-Threads
- 04-Synchronizing-Operations/02-Futures-and-Asynchronous-Tasks