定位與測試併發 Bug (Locating and Testing Concurrency Bugs)
Overview Table
| 概念 | 重點 |
|---|---|
| 程式碼評審 | 8 問清單;請他人評審、放置一段時間再看、向「橡皮鴨」解釋 |
| 定位技巧 | 單執行緒化排除一般 bug;單核消失/多核出現 → 併發 bug |
| 可測試性設計 | 關注點分離、去併發化測試、小心庫的內部共享狀態 |
| 三種測試技術 | 蠻力測試、組合模擬測試、專用庫檢測 |
| 建構測試程式碼 | std::promise 做 go 信號 + std::shared_future ready,確保「同時」開始 |
| 性能測試 | 可擴展性 (scalability);至少在單處理器與多核系統上各測一次 |
程式碼評審——發現潛在錯誤
最直接的定位手段是看程式碼,但要徹底找出併發問題,需要投入大量時間仔細梳理,而非休閒時掃一眼:
- 請同事/同伴評審:非作者會從不同角度思考「這段程式怎麼工作」,覆蓋作者沒想到的情況(勿上傳機密程式碼到網路)。
- 放置一段時間再自審:先去做別的事,回頭再看會「有點陌生」,能以旁觀者角度審視。
- 向他人詳細解釋:對象可以是玩具熊或橡皮鴨;逐行解釋「接下來會發生什麼、哪些資料被存取」,自問自答往往能揭露問題。寫詳細註解也有同樣效果。
評審多執行緒程式碼的問題清單
| # | 問題 |
|---|---|
| 1 | 併發存取時,哪些資料需要保護? |
| 2 | 如何確定資料已受到保護? |
| 3 | 是否會有多個執行緒同時執行這段程式碼? |
| 4 | 這個執行緒獲取了哪把互斥鎖 (mutex)? |
| 5 | 其他執行緒可能獲取哪些互斥鎖? |
| 6 | 兩執行緒間的操作是否有順序依賴?如何強制滿足? |
| 7 | 這個執行緒載入的資料仍然有效嗎?是否可能已被其他執行緒修改? |
| 8 | 若假設其他執行緒正在修改資料,意味著什麼?如何確保這種事不會發生? |
第 8 問迫使你思考執行緒間的關係,像偵探一樣驗證「這裡不可能出 bug」——對多把互斥鎖保護的資料結構(如第6章佇列的頭尾雙鎖)特別有效。第 7 問對應經典陷阱:先釋放再重新獲取互斥鎖之間,共享資料可能已被修改——當互斥鎖藏在物件內部而不可見時,特別容易中招。例:堆疊將 top() 與 pop() 分開,兩次呼叫之間內部鎖已釋放,其他執行緒可趁隙修改堆疊;解法是合併操作,讓同一把鎖保護整段執行。
定位併發相關的錯誤
多執行緒測試比單執行緒難好幾個數量級,因為執行緒排程不確定:相同輸入,結果可能時對時錯(例如程式碼中潛藏條件變數時)。定位的核心策略是縮小問題範圍:
- 單執行緒化測試:透過執行緒池的執行緒數參數、或手動改成單執行緒執行。單執行緒下問題仍出現 → 是一般 bug,而非併發 bug。
- 單核 vs 多核:問題在單核/單處理器系統上消失、在多核/多處理器系統上出現 → 幾乎可斷定是併發 bug(條件變數、同步或記憶體順序 (memory ordering) 問題)。
- 細粒度測試:分別對佇列的
push()與pop()做併發測試,優於一次測試整個佇列的全部功能——出問題時更容易剔除嫌疑。
問題發生在「未測試區域 (in the wild)」時要特別小心:出現在應用的多執行緒部分,不代表它就是多執行緒相關的 bug——先用單執行緒化排除一般 bug。
以併發佇列為例的測試情境清單
| 維度 | 應涵蓋的情境 |
|---|---|
| 單執行緒基準 | 單執行緒呼叫 push()/pop(),確認一般情況正常 |
| 空/滿佇列 × 多執行緒 push | 空佇列多執行緒 push();滿載佇列多執行緒 push() |
| 空/滿/非滿佇列 × 多執行緒 pop | 空、滿載、非滿載(元素數 < 執行緒數)佇列多執行緒 pop() |
| push + pop 混合 | 一執行緒在空/滿佇列 pop() 同時另一執行緒 push();空佇列上另一執行緒 pop() 時 push();多執行緒 pop() 同時多執行緒 push()(空/滿) |
還要考慮測試環境因素:
- 「多執行緒」是幾個執行緒(3、4,還是 1024)?
- 系統是否有足夠的處理器讓每個執行緒跑在自己的核心上?
- 測試要在哪種處理器架構上執行?
- 如何合理安排測試中的「同時」?(見下方〈建構多執行緒測試程式碼〉)
可測試性設計
多執行緒程式的可測試性比單執行緒更重要,因為情況複雜得多。容易測試的程式碼通常滿足:
- 每個函式和類別的職責關係清楚;
- 函式短小精悍;
- 測試用例可完全控制受測程式碼周邊的環境;
- 執行特定操作的程式碼集中而非分散;
- 寫程式之前就想好怎麼測試(用什麼輸入、什麼情況會出錯、如何激發潛在問題)。
去併發化測試 (de-concurrency)
併發測試的最佳策略之一:把併發拆掉再測。
- 多執行緒狀態機 → 拆成若干邏輯狀態,單執行緒驗證每個輸入事件/轉換的正確性(輸入事件在真實系統中來自其他執行緒);核心狀態機與訊息路由的程式碼再另外保證事件依正確順序傳遞。
- 資料處理 → 分割成「讀共享資料 / 變換資料 / 更新共享資料」三塊,中間的變換部分就能用單執行緒測試;麻煩的多執行緒測試縮小到只剩「讀」與「更新」。
某些庫用內部變數儲存狀態(如 strtok、errno 類設計),多執行緒呼叫同一庫函式時,這個狀態就成了隱形的共享資料,問題不會直接出現在「你的」共享資料存取程式碼中。對策:加上保護與同步,或改用執行緒安全版本的等價功能。
多執行緒測試技術
| 技術 | 原理 | 優點 | 缺點/適用範圍 |
|---|---|---|---|
| 蠻力測試 (brute force / 壓力測試) | 程式碼執行非常多遍、多執行緒同時執行,提高特殊排程出現的機率 | 簡單直接;測試粒度細(如單測 thread-safe queue)時,通過十億次可建立高度信心 | 可能誤導:測試環境 ≠ 部署環境時,問題根本不會在測試機上出現;受測物件太大時,排程序列太長,再多次也難有信心 |
| 組合模擬測試 (combination simulation) | 特殊軟體模擬執行環境,記錄資料存取序列、上鎖與原子操作,依 C++ 記憶體模型規則窮舉重跑所有可能組合,識別競爭條件與死結 | 對受測範圍是徹底的檢查——保證找出所有與排程相關的問題 | 組合數隨執行緒與操作數呈指數成長;只適合細粒度程式碼段而非整個應用;依賴模擬軟體的可用性 |
| 專用庫檢測 | 用特殊實作的同步原語庫(互斥鎖、鎖、條件變數)執行測試:存取被標記的共享資料時檢查「是否持有正確互斥鎖」;記錄每個執行緒的上鎖順序,發現不同執行緒以不同順序上鎖 → 回報潛在死結(即使本次執行沒真的死結) | 不需窮舉,開銷遠低於模擬;可由測試者控制排程(例如控制 notify_one() 喚醒哪個執行緒)驗證特定情境 |
不如模擬測試徹底;部分工具已存在於 C++ 標準庫實作中,沒有的可基於標準庫自行實作 |
在單處理器系統上測多執行緒應用:所有事情實際上串行化,許多競爭條件與快取乒乓 (cache ping-pong) 問題根本不會出現。架構差異也一樣:x86/x86-64 上原子載入無論 memory_order_relaxed 或 memory_order_seq_cst 通常是同一條指令,relaxed 用錯了也測不出來;換到記憶體順序指令更精細的架構(如 SPARC)就會爆發。跨平台應用必須在相關平台上實測。
建構多執行緒測試程式碼
測試中「同時」的安排是核心難題。每個測試可拆成四個部分:
- 環境布置程式碼(最先執行,如建立佇列);
- 執行緒設置程式碼(在每個執行緒上執行,如預先建構高開銷的物件,避免影響測試結果);
- 需要並發執行的受測程式碼;
- 並發結束後的斷言檢查(驗證最終狀態)。
以「一執行緒在空佇列 push()、同時另一執行緒 pop()」為例,合法結果有兩種:pop() 取回推入的資料且佇列變空;或 pop() 回報佇列為空、但 push() 已成功推入一筆。不允許的是:回報空的同時佇列其實有資料,或取回資料後佇列還有殘留。若 pop() 是阻塞式,測試更簡單:斷言「彈出值 == 推入值」且「佇列為空」。
go 信號 + ready futures 模式(代碼11.1)
void test_concurrent_push_and_pop_on_empty_queue() {
threadsafe_queue<int> q; // 1 環境布置
std::promise<void> go, push_ready, pop_ready; // 2 就緒/開始信號
std::shared_future<void> ready(go.get_future()); // 3 go 的共享 future
std::future<void> push_done; // 4 完成信號(在 try 外宣告)
std::future<int> pop_done;
try {
push_done = std::async(std::launch::async, // 5
[&q, ready, &push_ready]() {
push_ready.set_value(); // 我就緒了
ready.wait(); // 等 go 信號
q.push(42);
});
pop_done = std::async(std::launch::async, // 6
[&q, ready, &pop_ready]() {
pop_ready.set_value();
ready.wait();
return q.pop(); // 7 經 future 回傳彈出值
});
push_ready.get_future().wait(); // 8 等兩個執行緒都就緒
pop_ready.get_future().wait();
go.set_value(); // 9 按下「開始」鍵
push_done.get(); // 10 等待完成
assert(pop_done.get() == 42); // 11 斷言檢查
assert(q.empty());
} catch (...) {
go.set_value(); // 12 異常時也要放行,避免死結
throw;
}
}
主執行緒 push 執行緒 pop 執行緒
│ 建立佇列 q、go/ready
├─ async 啟動 ──────────► push_ready.set_value()
├─ async 啟動 ──────────────────────────────────► pop_ready.set_value()
│ 等 push_ready、pop_ready ready.wait() 阻塞 ready.wait() 阻塞
│ go.set_value() ───────────► q.push(42) ──同時──► return q.pop()
│ push_done.get()
│ assert(pop_done.get()==42); assert(q.empty())
設計要點:
- go 信號:一個
std::promise<void> go,每個執行緒持有其std::shared_future(以值捕獲拷貝)並在上面wait();主線程等所有 ready promise 都設值後才go.set_value()——確保所有執行緒同時解除阻塞開跑。 - 為什麼需要它:啟動執行緒很耗時,若沒有 go 信號,push 執行緒可能在 pop 執行緒啟動前就跑完了,測試就失去「並發」的意義。
- 用
std::async而非std::thread:std::launch::async保證每個任務有自己的執行緒;且 future 的解構函式會等待(匯入)任務完成,異常時更安全。 - future 宣告在 try 塊之外:異常發生時先
go.set_value()再重拋⑫——否則等待 go 的執行緒永遠阻塞,future 解構時等它們 → 測試程式自己死結。 - 此模式容易擴展到兩個以上的執行緒:每個執行緒加一個 ready promise 即可。
測試多執行緒程式碼的性能
使用併發的初衷是利用更多處理器核心提升效率,所以性能測試與正確性測試同樣重要:
- 可擴展性 (scalability):希望程式在 24 核機器上跑出接近 24 倍的處理能力。依 Amdahl 定律(見 8.4.2 節),只要有重要程式碼以單執行緒(串行)方式執行,加速比就被鎖死——分析後可能發現 24 核的理論上限只有 3 倍。測試前先回顧設計結構再對照實測。
- 競爭 (contention) 的權衡:處理器太少 → 執行緒排隊等待;處理器太多 → 對資料的競爭反而使等待變久。性能不必然隨核數單調上升。
- 實測不同配置:在對應系統上以不同執行緒數/核數組合實測,畫出性能曲線圖;最起碼要在單處理器系統與多處理器(多核)系統上各測一次。
Exam/Test Patterns
| 情境/關鍵字 | 答案 |
|---|---|
| 「評審時最該問的問題」 | 假設其他執行緒正在修改資料,意味著什麼?如何確保不會發生?(偵探式驗證) |
| 「先釋放鎖再重新獲取之間會怎樣?」 | 其他執行緒可能已修改共享資料;經典案例:top()/pop() 分離 → 合併操作用同一把鎖保護 |
| 「bug 單核消失、多核出現」 | 併發 bug(條件變數/同步/記憶體順序問題);反之單執行緒仍出現 → 一般 bug |
| 「跑十億次都過就沒 bug?」 | 蠻力測試可能誤導:單處理器測不出競爭、x86 relaxed 測不出 SPARC 上的錯 |
| 「窮舉所有排程組合的測試」 | 組合模擬測試;徹底但指數成長,只適合細粒度程式碼段 |
| 「回報『潛在死結』的工具」 | 專用庫檢測:記錄各執行緒上鎖順序,順序不一致即回報(即使本次沒死結) |
| 「如何讓測試執行緒『同時』開始?」 | std::promise<void> go 信號 + std::shared_future ready;每執行緒先設 ready promise 再 ready.wait() |
| 「測試程式異常時要做什麼?」 | go.set_value() 後再重拋,否則等待中的執行緒使測試程式自己死結 |
「測試用 std::async 還是 std::thread?」 |
std::async + std::launch::async:future 解構會等待任務完成,異常安全 |
| 「24 核只快 3 倍」 | 串行部分限制可擴展性(Amdahl 定律,8.4.2 節);另注意核太多時的競爭 |
Related Notes
- 11-Testing-and-Debugging/01-Concurrency-Bug-Types
- 11-Testing-and-Debugging/Practice-Testing-and-Debugging
- 06-Lock-Based-Data-Structures/02-Thread-Safe-Stack-and-Queue
- 04-Synchronizing-Operations/02-Futures-and-Asynchronous-Tasks
- 05-Memory-Model-and-Atomics/04-Memory-Ordering-Options
- 08-Designing-Concurrent-Code/04-Exception-Safety-and-Scalability