定位與測試併發 Bug (Locating and Testing Concurrency Bugs)

Overview Table

概念 重點
程式碼評審 8 問清單;請他人評審、放置一段時間再看、向「橡皮鴨」解釋
定位技巧 單執行緒化排除一般 bug;單核消失/多核出現 → 併發 bug
可測試性設計 關注點分離、去併發化測試、小心庫的內部共享狀態
三種測試技術 蠻力測試組合模擬測試專用庫檢測
建構測試程式碼 std::promisego 信號 + std::shared_future ready,確保「同時」開始
性能測試 可擴展性 (scalability);至少在單處理器與多核系統上各測一次

程式碼評審——發現潛在錯誤

最直接的定位手段是看程式碼,但要徹底找出併發問題,需要投入大量時間仔細梳理,而非休閒時掃一眼:

評審多執行緒程式碼的問題清單

# 問題
1 併發存取時,哪些資料需要保護?
2 如何確定資料已受到保護?
3 是否會有多個執行緒同時執行這段程式碼?
4 這個執行緒獲取了哪把互斥鎖 (mutex)?
5 其他執行緒可能獲取哪些互斥鎖?
6 兩執行緒間的操作是否有順序依賴?如何強制滿足?
7 這個執行緒載入的資料仍然有效嗎?是否可能已被其他執行緒修改?
8 假設其他執行緒正在修改資料,意味著什麼?如何確保這種事不會發生?
最關鍵的兩問

第 8 問迫使你思考執行緒間的關係,像偵探一樣驗證「這裡不可能出 bug」——對多把互斥鎖保護的資料結構(如第6章佇列的頭尾雙鎖)特別有效。第 7 問對應經典陷阱:先釋放再重新獲取互斥鎖之間,共享資料可能已被修改——當互斥鎖藏在物件內部而不可見時,特別容易中招。例:堆疊將 top()pop() 分開,兩次呼叫之間內部鎖已釋放,其他執行緒可趁隙修改堆疊;解法是合併操作,讓同一把鎖保護整段執行。

定位併發相關的錯誤

多執行緒測試比單執行緒難好幾個數量級,因為執行緒排程不確定:相同輸入,結果可能時對時錯(例如程式碼中潛藏條件變數時)。定位的核心策略是縮小問題範圍:

bug 出現在多執行緒區段 ≠ 併發 bug

問題發生在「未測試區域 (in the wild)」時要特別小心:出現在應用的多執行緒部分,不代表它就是多執行緒相關的 bug——先用單執行緒化排除一般 bug。

以併發佇列為例的測試情境清單

維度 應涵蓋的情境
單執行緒基準 單執行緒呼叫 push()/pop(),確認一般情況正常
空/滿佇列 × 多執行緒 push 空佇列多執行緒 push();滿載佇列多執行緒 push()
空/滿/非滿佇列 × 多執行緒 pop 空、滿載、非滿載(元素數 < 執行緒數)佇列多執行緒 pop()
push + pop 混合 一執行緒在空/滿佇列 pop() 同時另一執行緒 push();空佇列上另一執行緒 pop()push();多執行緒 pop() 同時多執行緒 push()(空/滿)

還要考慮測試環境因素:

  1. 「多執行緒」是幾個執行緒(3、4,還是 1024)?
  2. 系統是否有足夠的處理器讓每個執行緒跑在自己的核心上?
  3. 測試要在哪種處理器架構上執行?
  4. 如何合理安排測試中的「同時」?(見下方〈建構多執行緒測試程式碼〉)

可測試性設計

多執行緒程式的可測試性比單執行緒更重要,因為情況複雜得多。容易測試的程式碼通常滿足:

去併發化測試 (de-concurrency)

併發測試的最佳策略之一:把併發拆掉再測

小心庫函式的內部共享狀態

某些庫用內部變數儲存狀態(如 strtokerrno 類設計),多執行緒呼叫同一庫函式時,這個狀態就成了隱形的共享資料,問題不會直接出現在「你的」共享資料存取程式碼中。對策:加上保護與同步,或改用執行緒安全版本的等價功能。

多執行緒測試技術

技術 原理 優點 缺點/適用範圍
蠻力測試 (brute force / 壓力測試) 程式碼執行非常多遍、多執行緒同時執行,提高特殊排程出現的機率 簡單直接;測試粒度細(如單測 thread-safe queue)時,通過十億次可建立高度信心 可能誤導:測試環境 ≠ 部署環境時,問題根本不會在測試機上出現;受測物件太大時,排程序列太長,再多次也難有信心
組合模擬測試 (combination simulation) 特殊軟體模擬執行環境,記錄資料存取序列、上鎖與原子操作,依 C++ 記憶體模型規則窮舉重跑所有可能組合,識別競爭條件與死結 對受測範圍是徹底的檢查——保證找出所有與排程相關的問題 組合數隨執行緒與操作數呈指數成長;只適合細粒度程式碼段而非整個應用;依賴模擬軟體的可用性
專用庫檢測 特殊實作的同步原語庫(互斥鎖、鎖、條件變數)執行測試:存取被標記的共享資料時檢查「是否持有正確互斥鎖」;記錄每個執行緒的上鎖順序,發現不同執行緒以不同順序上鎖 → 回報潛在死結(即使本次執行沒真的死結) 不需窮舉,開銷遠低於模擬;可由測試者控制排程(例如控制 notify_one() 喚醒哪個執行緒)驗證特定情境 不如模擬測試徹底;部分工具已存在於 C++ 標準庫實作中,沒有的可基於標準庫自行實作
蠻力測試的經典誤導案例

單處理器系統上測多執行緒應用:所有事情實際上串行化,許多競爭條件與快取乒乓 (cache ping-pong) 問題根本不會出現。架構差異也一樣:x86/x86-64 上原子載入無論 memory_order_relaxedmemory_order_seq_cst 通常是同一條指令,relaxed 用錯了也測不出來;換到記憶體順序指令更精細的架構(如 SPARC)就會爆發。跨平台應用必須在相關平台上實測

建構多執行緒測試程式碼

測試中「同時」的安排是核心難題。每個測試可拆成四個部分:

  1. 環境布置程式碼(最先執行,如建立佇列);
  2. 執行緒設置程式碼(在每個執行緒上執行,如預先建構高開銷的物件,避免影響測試結果);
  3. 需要並發執行的受測程式碼;
  4. 並發結束後的斷言檢查(驗證最終狀態)。

以「一執行緒在空佇列 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())

設計要點:

測試多執行緒程式碼的性能

使用併發的初衷是利用更多處理器核心提升效率,所以性能測試與正確性測試同樣重要:


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 節);另注意核太多時的競爭