共享資料練習題 (Practice - Sharing Data)
Related Concepts
- 03-Sharing-Data/01-Race-Conditions-and-Mutexes
- 03-Sharing-Data/02-Deadlock-and-Lock-Management
- 03-Sharing-Data/03-Alternative-Data-Protection
| 關鍵字 | 答案 |
|---|---|
| 未同步並發修改同一物件 | 資料競爭 → 未定義行為 |
| bug 在除錯模式消失 | 競爭條件時間敏感,除錯改變時序 |
| 回傳受保護資料的指標/引用 | 保護失效;切勿傳出鎖作用域 |
| 每個函式都上鎖仍出錯 | 介面固有競爭(empty/top/pop) |
| 同時要鎖兩個以上 mutex | std::lock / std::scoped_lock(全有或全無) |
| 執行期檢查上鎖順序 | 層級鎖(thread_local 記錄當前層級) |
| 延遲上鎖/中途解鎖/轉移所有權 | std::unique_lock |
| 雙重檢查鎖定 (DCLP) | 資料競爭 UB → std::call_once |
| 單例/全域唯一實例 | C++11 static 區域變數初始化執行緒安全 |
| 讀多寫少 | std::shared_mutex:讀 shared_lock、寫 lock_guard |
| 同執行緒重複鎖 std::mutex | UB → recursive_mutex,但通常該重構 |
Question 1 - 競爭條件與資料競爭 [recall]
「競爭條件」與 C++ 標準所定義的「資料競爭」有何區別?為什麼競爭條件造成的 bug 常在除錯模式下完全消失?
競爭條件泛指結果取決於多執行緒執行的相對順序;不變量未被破壞者為良性(如兩執行緒同時往佇列加任務),可以接受。資料競爭 (data race) 是 C++ 標準術語,指未同步地並發修改同一物件,直接導致未定義行為。惡性競爭需要 CPU 指令序恰好交錯才觸發,出現機率低、對時間敏感——除錯模式改變了程式的執行時序,所以 bug 常常「消失」;系統負載增加時反而更易復現。
Question 2 - lock_guard 與引用外洩 [recall]
為什麼不建議直接呼叫
std::mutex的 lock()/unlock(),而要用std::lock_guard?即使每個成員函式都正確上鎖,哪種常見寫法仍會讓保護「形同虛設」?
手動呼叫必須在每個函式出口(含例外路徑)配對 unlocklock_guard 以 RAII 在建構時上鎖、解構時解鎖,例外時也保證釋放。即使全部上鎖,只要成員函式把受保護資料的指標或引用傳出鎖的作用域(回傳值、輸出參數、或傳給使用者提供的函式,呼叫端就能在無鎖狀態下存取資料。準則:切勿將受保護資料的指標或引用傳遞到互斥鎖作用域之外。
Question 3 - stack 介面固有競爭 [recall]
一個內部用 mutex 保護的共享堆疊,使用者寫
if(!s.empty()){ int v = s.top(); s.pop(); }。指出這段程式碼中的兩個競爭條件,並說明為何「換成無鎖實作」也救不了。
- empty→top 競爭:①②之間另一執行緒可能 pop() 掉最後一個元素,對空堆疊呼叫 top() 是未定義行為。
- top→pop 競爭:兩執行緒交錯執行時可能同一值被處理兩次、另一值被丟棄從未處理——看似無錯,更難定位。
這是介面設計本身的問題(檢查與取值/移除被拆成多個呼叫),與實作方式無關,無鎖實作同樣中招;必須重新設計介面,把操作合併在同一把鎖內(如 threadsafe_stack 的 pop 同時完成檢查+取值+移除)。
Question 4 - std::lock 與 adopt_lock [recall]
std::lock(lhs.m, rhs.m);之後緊接兩行std::lock_guard<std::mutex> a(lhs.m, std::adopt_lock);,std::adopt_lock的意義是什麼?若std::lock鎖第二個 mutex 時拋出例外,第一個 mutex 會怎樣?C++17 有何更簡潔的寫法?
std::adopt_lock 告訴 lock_guard:mutex 已被上鎖,只需接管、負責解構時解鎖,不要再上鎖。std::lock 具全有或全無語義——鎖第二個 mutex 時若拋例外,第一個會自動釋放,不會留下半鎖狀態(內部以 try_lock() 作為避免死結演算法的一部分)。C++17 可用一行 std::scoped_lock guard(lhs.m, rhs.m);(可變參數 RAII + CTAD)取代整組樣板。
Question 5 - DCLP、call_once 與 static 初始化 [recall]
雙重檢查鎖定 (DCLP) 為什麼聲名狼藉?C++ 標準庫對「昂貴資源只初始化一次」與「全域唯一實例」各提供什麼正解?
DCLP 的第一次無鎖讀取指標與上鎖後的寫入之間沒有同步,是資料競爭 → 未定義行為;競爭不只在指標,還包括指標指向的物件——執行緒可能看到指標非空,卻看不到新建物件的完整內容。正解:
std::call_once+std::once_flag:保證初始化恰好完整執行一次,開銷比顯式鎖低;可傳成員函式 +this做類別成員的延遲初始化。- C++11
static區域變數:標準保證初始化只發生在一個執行緒中,其他執行緒等它完成——單例的最簡解法。
注意:std::mutex 與 std::once_flag 不可複製、不可移動。
Question 6 - shared_mutex 讀寫鎖 [recall]
DNS 快取表讀多寫少,用哪種 mutex?讀者與寫者各用什麼鎖類型?C++11/14/17 各提供什麼?
用讀寫鎖:讀者以 std::shared_lock<std::shared_mutex> 取得共享鎖(多執行緒可同時持有);寫者以 std::lock_guard<std::shared_mutex> 或 std::unique_lock<std::shared_mutex> 取得獨占鎖。互斥規則:有人持共享鎖時,想拿獨占鎖者阻塞至全部釋放;有人持獨占鎖時,誰都拿不到任何鎖。版本:C++11 無(用 Boost)、C++14 只有 std::shared_timed_mutex、C++17 新增 std::shared_mutex(操作較少、效能較佳)。效能仍取決於處理器數與讀寫負載比,需實測。
Question 7 - 鎖層級違規診斷 [application]
系統定義
hierarchical_mutex層級:high=10000、other=6000、low=5000。thread_b 先鎖 other_mutex(6000),再呼叫會鎖 high_level_mutex(10000) 的函式。會發生什麼事?為何hierarchical_mutex能直接放進std::lock_guard?
違反層級規則——已持 6000 卻想鎖更高的 10000,check_for_hierarchy_violation() 立即拋出例外(或終止程式)。關鍵機制:static thread_local 變數記錄當前執行緒的層級(初始 ULONG_MAX,故第一把鎖必能上),只允許獲取層級更低的鎖;解鎖須依相反順序並恢復前值。優點:執行期偵測、無時間依賴——不必等死結真的發生,即使當下無競爭也會報錯。因為它提供 lock()/unlock()/try_lock() 三件套,滿足 mutex 介面要求,可直接用於 std::lock_guard<hierarchical_mutex>。
Question 8 - unique_lock 與鎖粒度 [application]
get_and_process_data()需要:取共享資料塊 → 呼叫耗時的 process()(不碰共享資料)→ 寫回結果。用std::lock_guard全程持鎖被抱怨太慢,如何用std::unique_lock改善?寫出關鍵程式碼。
std::unique_lock 支援手動 lock()/unlock(),可在不需共享資料的區段中途放鎖,把持鎖時間縮到最短:
std::unique_lock<std::mutex> my_lock(the_mutex);
some_class data = get_next_data_chunk();
my_lock.unlock(); // process() 不碰共享資料,不持鎖
result_type result = process(data);
my_lock.lock(); // 寫回前再上鎖
write_result(data, result);
原則:持鎖時間盡可能短,耗時操作(尤其檔案 I/O,比記憶體慢成百上千倍)絕不在持鎖時做。unique_lock 內部以旗標記錄是否擁有 mutex(owns_lock() 可查),代價是比 lock_guard 佔空間、稍慢——夠用就選 lock_guard/scoped_lock。
Question 9 - threadsafe_stack 的 pop 設計 [analysis]
std::stack把 top() 與 pop() 分開是為了解決什麼問題?threadsafe_stack 合併它們後,這個問題如何在「選項 1(傳入引用)+ 選項 3(回傳 shared_ptr)」的設計下被化解?分析各選項的代價。
分開是為了例外安全(Cargill 問題):若 pop() 直接回傳值,stack<vector<int>> 複製回傳值時可能拋 std::bad_alloc——值已移出堆疊、複製卻失敗,資料永久遺失。但這個分割正是介面競爭的元兇。合併後的化解:
- 選項 1
pop(T&):賦值在移除前完成,失敗不丟資料;代價是需先建構實例、型別必須可賦值。 - 選項 2(要求不拋例外的複製/移動建構):以
std::is_nothrow_*_constructible檢查;限制太強,許多自訂型別不合格。 - 選項 3
shared_ptr<T> pop():先make_shared建好回傳值、最後才data.pop(),複製指標不拋例外;代價是記憶體配置開銷(對int等簡單型別划不來)。 - 採 1+3 提供多載讓使用者選,並削減介面(無 top()、無 swap()、刪除賦值)——檢查與彈出全在同一把鎖內完成,介面競爭與例外安全同時解決。
Question 10 - recursive_mutex 是解藥還是壞味道 [analysis]
成員函式 A(已上鎖)呼叫也要上鎖的成員函式 B,對
std::mutex重複上鎖是 UB。同事提議「換成std::recursive_mutex就能跑了」。分析這個方案的問題與更好的做法。
std::recursive_mutex 允許同一執行緒多次上鎖(lock() 三次就要 unlock() 三次),表面上「能跑」,但通常是設計壞味道:A 呼叫 B 時,類別的不變量可能正處於被破壞的中間狀態,B 卻照常執行——鎖給的安全感是假的,B 依賴的前提可能根本不成立。
較好的做法:抽出一個不上鎖的私有成員函式承載共同邏輯(約定:呼叫前必須已持鎖),A、B 各自上鎖後呼叫它,並明確思考呼叫當下資料的狀態。這讓「誰持鎖、資料處於什麼狀態」在設計上一目瞭然,而不是靠 recursive_mutex 掩蓋問題。
| 關鍵字 | 答案 |
|---|---|
| 資料競爭 | 未同步並發修改同一物件 → UB |
| 除錯模式 bug 消失 | 競爭條件時間敏感 |
| 指標/引用外洩 | 保護失效;不傳出鎖作用域 |
| empty/top/pop 分離 | 介面固有競爭 → 合併操作重設計 |
| pop 回傳值拋例外 | Cargill 問題 → pop(T&) / shared_ptr |
| 鎖兩個以上 mutex | std::lock(全有或全無)/ scoped_lock |
| adopt_lock vs defer_lock | 接管已鎖 vs 建構不上鎖(unique_lock) |
| 層級鎖 | thread_local 當前層級;違反即拋例外 |
| 中途解鎖/轉移所有權 | unique_lock(可移動、owns_lock) |
| 持鎖做耗時操作 | 粒度過粗;持鎖時間最短化 |
| DCLP | 資料競爭 UB → call_once / static 區域變數 |
| 讀多寫少 | shared_mutex:讀 shared_lock、寫獨占 |
| 重複上鎖「就能跑」 | recursive_mutex 掩蓋不變量破壞 → 抽私有無鎖函式 |