競爭條件與互斥鎖 (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 節)

避免惡性競爭條件的三種方式

  1. 保護機制(鎖):最簡單——確保只有修改中的執行緒能看到不變量中間狀態;其他執行緒看到的修改「不是還沒開始,就是已經完成」
  2. 無鎖 (lock-free) 編程:修改資料結構與不變量的設計,使修改成為一系列不可分割的變更——很難寫對,記憶體模型的細微差異都會放大工作量(見第7章)
  3. 軟體交易記憶體 (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();
}
頭文件

std::mutexstd::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);
}
順序 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;
};
鎖粒度的另一面

鎖太細(只保護單一操作)產生介面競爭;鎖太粗(如早期 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