測試與除錯練習題 (Practice - Testing and Debugging)
Related Concepts
- 11-Testing-and-Debugging/01-Concurrency-Bug-Types
- 11-Testing-and-Debugging/02-Locating-and-Testing-Concurrency-Bugs
| 關鍵字 | 答案 |
|---|---|
| 兩大類併發 bug | 不必要的阻塞 + 競爭條件 |
| 卡住 + CPU 高 | 活結;卡住 + CPU 低 → 死結 |
| 未同步存取共享記憶體 | 資料競爭 → 未定義行為 |
| 懸空指標/雙重釋放 | 破壞不變量 |
| 執行緒引用已銷毀的區域變數 | 生命週期問題 |
| 單核消失、多核出現 | 併發 bug(同步/記憶體順序) |
| 跑很多遍賭排程 | 蠻力測試(可能誤導) |
| 窮舉所有排程組合 | 組合模擬測試(指數成長) |
| 記錄上鎖順序、報潛在死結 | 專用庫檢測 |
| 讓執行緒「同時」開跑 | promise go 信號 + shared_future ready |
Question 1 - 併發 bug 分類 [recall]
與併發直接相關的錯誤可分為哪兩大類?各自再細分為哪些子類型?
兩大類:不必要的阻塞與競爭條件。
- 不必要的阻塞:死結 (deadlock)、活結 (livelock)、I/O 阻塞/外部輸入。
- 競爭條件:資料競爭 (data race)、破壞不變量、生命週期問題。
Question 2 - 死結 vs 活結 [recall]
死結與活結的表現都可能是「程式停止回應」,兩者的本質差異與快速判別指標是什麼?
死結是執行緒阻塞等待互相持有的資源;活結的執行緒不是阻塞,而是在迴圈中持續檢查(如自旋鎖)。
快速判別:CPU 使用率——死結時 CPU 低,活結時 CPU 居高不下。不嚴重的活結可用隨機排程化解。
Question 3 - 破壞不變量的表現 [recall]
「破壞不變量」這類競爭條件錯誤有哪三種典型表現?
- 懸空指標:要存取的資料已被其他執行緒刪除;
- 隨機記憶體錯誤:局部更新導致執行緒讀到不一致的資料;
- 雙重釋放:如兩個執行緒同時對同一佇列
pop(),刪除同一筆關聯資料。
這是「基於資料」的問題;操作間有順序依賴卻未正確同步時也會發生(破壞存取順序)。
Question 4 - 蠻力測試的缺點 [recall]
蠻力測試(壓力測試)跑了十億次都通過,為什麼仍可能誤導你?
因為測試環境可能與部署環境不同,問題根本不會在測試機上出現:
- 經典案例:單處理器系統上測多執行緒,一切實際串行,競爭條件與快取乒乓不會出現;
- 架構差異:x86 上原子載入無論
memory_order_relaxed或seq_cst通常是同一條指令,錯誤的 relaxed 用法要到 SPARC 等記憶體順序更精細的架構上才爆發。
另外,受測物件太大時排程序列過長,再多次執行也難覆蓋。跨平台應用必須在相關平台上實測。
Question 5 - 組合模擬測試 [recall]
組合模擬測試 (combination simulation) 的原理是什麼?為什麼不適合拿來測整個應用?
用特殊軟體模擬執行環境,記錄資料存取序列、上鎖與原子操作,再依 C++ 記憶體模型規則窮舉重跑所有可能的排程組合,識別競爭條件與死結——對受測範圍是徹底的檢查。
不適合整個應用,因為組合數隨執行緒數與操作數呈指數成長,耗時過長;只適合細粒度的程式碼段,且依賴模擬軟體的可用性。
Question 6 - 測試程式碼的四個組成 [recall]
建構多執行緒測試程式碼時,每個測試可拆成哪四個部分?
- 環境布置程式碼(最先執行,如建立佇列);
- 執行緒設置程式碼(在每個執行緒上執行;高開銷物件在此預先建構,避免干擾測試);
- 需要並發執行的受測程式碼;
- 並發結束後的斷言檢查(如
assert(pop_done.get()==42)、assert(q.empty()))。
Question 7 - 單核/多核定位 [application]
你的程式在多執行緒模式下偶爾輸出錯誤結果。把它放到單核機器上跑(仍是多執行緒)問題消失;改成單執行緒跑也正常。你會如何推論,下一步查什麼?
單執行緒下正常 → 排除一般(非併發)bug;單核消失、多核出現 → 幾乎可斷定是併發 bug。
下一步優先檢查:條件變數的使用、執行緒間同步、記憶體順序 (memory ordering) 是否正確(如錯用 relaxed 原子操作、共享資料未加鎖)。可搭配專用庫檢測或對可疑區段做組合模擬測試。
Question 8 - 設計「同時」測試 [application]
要測試「一執行緒對空佇列 push、同時另一執行緒 pop」,如何確保兩個操作真的並發發生,而不是 push 早就跑完了?
使用 go 信號 + ready futures 模式(代碼11.1):
- 建立
std::promise<void> go與std::shared_future<void> ready(go.get_future()),每個測試執行緒以值拷貝 ready; - 每個執行緒各有一個 ready promise(
push_ready/pop_ready):先set_value()表示就緒,再ready.wait()阻塞; - 主執行緒等所有 ready promise 就緒後才
go.set_value(),所有執行緒同時解除阻塞。
因為啟動執行緒很耗時,沒有 go 信號時 push 執行緒可能在 pop 執行緒啟動前就完成,測試便失去並發意義。異常時必須go.set_value()再重拋,避免測試程式自己死結。
Question 9 - 三種測試技術取捨 [analysis]
比較蠻力測試、組合模擬測試與專用庫檢測:覆蓋徹底度、成本、適用範圍各如何?若你要驗證一個新寫的 lock-free 佇列與一整個伺服器應用,分別選哪種?
| 技術 | 徹底度 | 成本 | 適用範圍 |
|---|---|---|---|
| 蠻力測試 | 低(靠機率) | 低 | 任何規模;粒度細時信心較高 |
| 組合模擬 | 徹底(窮舉排程) | 指數成長 | 只適合細粒度程式碼段 |
| 專用庫檢測 | 中(鎖持有檢查、鎖序記錄→潛在死結) | 中 | 使用標準同步原語的程式碼 |
lock-free 佇列:細粒度、正確性極難靠肉眼保證 → 組合模擬測試(必要時輔以蠻力壓測)。
整個伺服器應用:模擬窮舉不可行 → 專用庫檢測 + 蠻力測試,並在多種硬體配置(單核/多核/目標架構)上執行。
Question 10 - 阻塞型 vs 競爭型的除錯策略 [analysis]
阻塞型 bug(死結/活結)與競爭型 bug(資料競爭/破壞不變量)在症狀與除錯手段上有何本質差異?為什麼後者被稱為「共享記憶體的詛咒」?
阻塞型:表現為掛起、無回應、任務超時,狀態停在現場——可直接用除錯器附掛 (attach) 到卡住的執行緒觀察誰持有/等待哪把鎖,重現性較穩定。
競爭型:表現為隨機崩潰、錯誤輸出,依賴執行時序而時對時錯;破壞現場(被覆寫的記憶體)往往離肇因很遠,難以定位。
「詛咒」的原因:應用中任何執行緒都可能覆寫其他執行緒可存取的資料,事後除錯幾乎無從追起——必須事前預防:限制執行緒可存取的資料範圍、正確使用同步,並以評審清單+可測試性設計把問題擋在前期。
| 關鍵字 | 答案 |
|---|---|
| 兩大類 | 不必要的阻塞 / 競爭條件 |
| CPU 高的「卡住」 | 活結(自旋);CPU 低 → 死結 |
| 資料競爭 | 未同步存取 → 未定義行為 |
| 生命週期問題 | 引用已銷毀資料;join() 須涵蓋異常路徑 |
| 單執行緒仍出錯 | 一般 bug;單核消失多核出現 → 併發 bug |
| 蠻力測試 | 便宜但可能誤導(環境/架構差異) |
| 組合模擬 | 徹底但指數成長 → 細粒度程式碼專用 |
| 專用庫 | 鎖持有檢查、鎖序記錄 → 潛在死結 |
| 「同時」開始 | promise go + shared_future ready;異常時先 set go |
| 性能測試 | 可擴展性受串行部分限制;單核與多核系統都要測 |