競爭條件與互斥鎖 (Race Conditions and Mutexes)
Overview Table
| 概念 | 重點 |
|---|---|
| 不變量 (invariant) | 對資料結構恆成立的描述;修改途中會暫時被破壞 |
| 競爭條件 (race condition) | 結果取決於多執行緒執行的相對順序 |
| 資料競爭 (data race) | 未同步地並發修改同一物件 → 未定義行為 |
| 避免惡性競爭的三種方式 | 鎖保護 / 無鎖 (lock-free) 設計 / 軟體交易記憶體 (STM) |
std::mutex + std::lock_guard |
RAII 上鎖:建構時鎖、解構時解,例外也保證解鎖 |
| 指標/引用外洩 | 受保護資料的指標或引用傳出鎖範圍 = 保護形同虛設 |
| 介面固有的競爭條件 | empty()/top()/pop() 分離導致競爭,鎖救不了,必須改介面 |
共享資料的問題:不變量被破壞
共享資料的麻煩全來自「修改」:若所有資料唯讀,任何執行緒讀到的都一樣,毫無問題。一旦有執行緒修改,就可能讓其他執行緒看到不變量的中間狀態。
以雙向鏈結串列為例:不變量是「A 的 next 指向 B,則 B 的 prev 指回 A」。刪除節點 N 需要分別更新兩側指標——只更新完一側時,不變量已被破壞:
刪除節點 N 的步驟:
a. 找到節點 N
b. N->prev 的 next 改指 N->next ← 此刻不變量已破壞!
c. N->next 的 prev 改指 N->prev ← 到這裡才恢復
d. 刪除 N
[A] <──> [N] <──> [B]
│ ↑
└── 步驟 b 後:A.next→B,但 B.prev 仍→N(不一致)
- 另一執行緒此刻遍歷串列:可能讀到已刪一半的節點、跳過節點
- 若第二個執行緒同時刪相鄰節點:資料結構永久損壞、程式崩潰
競爭條件:良性 vs 惡性
競爭條件指結果取決於多個執行緒操作的相對執行順序(如搶電影票:座位取決於購買順序)。
| 類型 | 特徵 | 例子 |
|---|---|---|
| 良性競爭 | 不變量始終保持,順序不影響正確性 | 兩執行緒同時往佇列加任務 |
| 惡性競爭 | 不變量被破壞,通常涉及修改多個資料塊 | 雙向鏈結串列刪除(兩個指標) |
| 資料競爭 (data race) | C++ 標準術語:未同步的並發修改單一物件 | → 未定義行為(見 5.1.2 節) |
- 惡性競爭出現機率低、難重現:CPU 指令序需恰好交錯才觸發
- 時間敏感:負載增加時更易出現;除錯模式常讓 bug 消失(改變了時序)
避免惡性競爭條件的三種方式
- 保護機制(鎖):最簡單——確保只有修改中的執行緒能看到不變量中間狀態;其他執行緒看到的修改「不是還沒開始,就是已經完成」
- 無鎖 (lock-free) 編程:修改資料結構與不變量的設計,使修改成為一系列不可分割的變更——很難寫對,記憶體模型的細微差異都會放大工作量(見第7章)
- 軟體交易記憶體 (STM):像資料庫交易一樣記錄-合併-提交,衝突則重來——C++ 標準無直接支援(僅有技術規範 TS)
std::mutex 與 std::lock_guard
存取共享資料前上鎖、結束後解鎖;其他執行緒必須等鎖釋放才能存取。不建議直接呼叫 lock()/unlock()——必須在每個出口(含例外路徑)配對解鎖,改用 RAII 類 std::lock_guard。
std::list<int> some_list;
std::mutex some_mutex;
void add_to_list(int v) {
std::lock_guard<std::mutex> guard(some_mutex); // 建構上鎖、解構解鎖
some_list.push_back(v);
}
bool list_contains(int v) {
std::lock_guard<std::mutex> guard(some_mutex); // 與上面互斥
return std::find(some_list.begin(), some_list.end(), v) != some_list.end();
}
- 兩函式共用同一 mutex →
list_contains()絕不會看到add_to_list()修改到一半的串列 - C++17 模板參數推導 (CTAD):可簡寫
std::lock_guard guard(some_mutex);,也可用加強版std::scoped_lock guard(some_mutex); - 物件導向慣例:mutex 與受保護資料應包成同一類別的
private成員,而非全域變數;所有成員函式上鎖後操作、返回前解鎖
std::mutex、std::lock_guard 都宣告於 <mutex>。
不要傳出受保護資料的指標或引用
只要有任何成員函式把受保護資料的指標或引用傳到鎖的作用域之外(回傳值、輸出參數、或傳給使用者提供的函式),保護就被繞過:
class data_wrapper {
some_data data; std::mutex m;
public:
template<typename Function>
void process_data(Function func) {
std::lock_guard<std::mutex> l(m);
func(data); // 危險:把「受保護」資料交給使用者函式
}
};
some_data* unprotected;
void malicious_function(some_data& d) { unprotected = &d; } // 偷走引用
// 之後 unprotected->do_something() 就在無鎖狀態下存取資料
切勿將受保護資料的指標或引用傳遞到互斥鎖作用域之外——無論是函式回傳、存到外部可見的記憶體,還是以參數形式傳給使用者提供的函式。C++ 對此無法提供任何自動防護。
介面固有的競爭條件:stack 的 empty/top/pop
即使每個成員函式都上了鎖,介面設計本身仍可能有競爭條件——這與實作方式無關(無鎖實作同樣中招)。以 std::stack 介面為例:
stack<int> s;
if (!s.empty()) { // 1 檢查非空
int const value = s.top(); // 2 之間可能被別的執行緒 pop 光 → UB
s.pop(); // 3 之間可能被別的執行緒也 pop → 語義錯亂
do_something(value);
}
- empty→top 競爭:單執行緒下安全;共享物件時,①②之間另一執行緒可能
pop()掉最後一個元素,空堆疊上呼叫top()是未定義行為 - top→pop 競爭:兩執行緒交錯執行時可能「同一值被處理兩次、另一值被丟棄從未處理」——看起來沒有錯誤,比崩潰更難定位:
| 順序 | Thread A | Thread B |
|---|---|---|
| 1 | if(!s.empty()) |
|
| 2 | if(!s.empty()) |
|
| 3 | value = s.top(); |
|
| 4 | value = s.top(); ← 讀到同一值 |
|
| 5 | s.pop(); |
|
| 6 | do_something(value); |
s.pop(); ← 丟掉沒人處理的值 |
為什麼 std::stack 要把 top 和 pop 分開?
例外安全考量(Tom Cargill 的問題):若 pop() 直接回傳彈出值,stack<vector<int>> 在複製回傳值時可能拋出 std::bad_alloc——值已從堆疊移除、複製卻失敗,資料就永久遺失。分成 top()(取值)+pop()(移除)可避免;但這個分割正是製造介面競爭的元兇。
解決選項
| 選項 | 作法 | 優點 | 缺點 |
|---|---|---|---|
| 1. 傳入引用 | pop(T& value) |
賦值在移除前完成,無遺失 | 需先建構實例;型別必須可賦值 |
| 2. 要求不拋例外的複製/移動建構 | 以 std::is_nothrow_*_constructible 檢查 |
回傳值安全 | 限制太強,許多自訂型別不合格 |
| 3. 回傳指向彈出值的指標 | std::shared_ptr<T> pop() |
複製指標不拋例外 | 記憶體配置管理開銷(對 int 等簡單型別划不來) |
| 4. 選項 1+2 或 1+3 | 提供多載讓使用者選 | 通用程式碼的彈性 | 介面略複雜 |
threadsafe_stack(採選項 1+3)
struct empty_stack : std::exception { const char* what() const throw(); };
template<typename T>
class threadsafe_stack {
std::stack<T> data;
mutable std::mutex m; // mutable:const 成員函式也要上鎖
public:
void push(T v); // 上鎖後 push
std::shared_ptr<T> pop(); // 空則拋 empty_stack;先 make_shared 再 pop
void pop(T& value); // 空則拋 empty_stack;先賦值再 pop
bool empty() const;
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
};
- 削減介面換取最大安全:五個操作縮成三個(
push/pop/empty),無top()、無swap()、刪除賦值 pop()內部先檢查空、先建好回傳值make_shared)、最後才data.pop(→ 檢查與彈出在同一把鎖內完成,且例外安全- 複製建構函式在函式本體內先鎖
other.m再複製(不能用成員初始化列表,否則複製時未持鎖)
鎖太細(只保護單一操作)產生介面競爭;鎖太粗(如早期 Linux 的全域核心鎖)則扼殺並發性能——雙核性能比兩台單核機器還差。粒度取捨見 03-Sharing-Data/02-Deadlock-and-Lock-Management。
Exam/Test Patterns
| 情境/關鍵字 | 答案 |
|---|---|
| 「bug 在除錯模式下消失」「難重現」 | 競爭條件對時間敏感,除錯器改變時序 |
| 「未同步地並發修改同一物件」 | 資料競爭 → 未定義行為(C++ 標準定義) |
| 「兩執行緒同時加任務進佇列,不變量沒破壞」 | 良性競爭,可接受 |
| 「成員函式回傳受保護資料的引用/指標」 | 保護失效;切勿傳出鎖作用域之外 |
「每個成員函式都上鎖了,empty() 後 top() 仍崩潰」 |
介面固有競爭,與實作無關,需合併操作重設計介面 |
「pop() 回傳值時複製拋出例外,資料遺失」 |
Cargill 例外安全問題 → 傳入引用或回傳 std::shared_ptr |
| 「不用鎖的執行緒安全」 | 無鎖設計(難寫對)或 STM(C++ 無直接支援) |
| 「手動 lock()/unlock() 忘了在例外路徑解鎖」 | 用 RAII:std::lock_guard |