條件變數與執行緒安全佇列 (Condition Variables and Thread-Safe Queue)
Overview Table
| 概念 | 重點 |
|---|---|
| 等待事件的三種方式 | 忙碌輪詢 / 定時休眠 sleep_for / 條件變數(首選) |
std::condition_variable |
搭配 std::unique_lock<std::mutex>;wait / notify_one / notify_all |
| 虛假喚醒 (spurious wakeup) | wait() 可能無通知而醒來 → 必須用 predicate 重新檢查條件 |
condition_variable_any |
可搭配任何滿足最低要求的鎖;較通用但開銷較大 |
threadsafe_queue |
合併 front()+pop() 消除介面競爭;提供 try_pop / wait_and_pop |
等待事件的三種方式
當一個執行緒要等待另一個執行緒完成工作(例如「夜車到站要有人叫醒你」),有三種做法。前兩種都有明顯缺陷,C++ 標準庫提供的條件變數 (condition variable) 是首選:等待的執行緒真正休眠、事件發生時被另一執行緒主動喚醒。
| 方式 | 作法 | 缺點 |
|---|---|---|
| 忙碌輪詢 | 迴圈中持鎖反覆檢查共享旗標 | 浪費 CPU 時間;持鎖期間阻礙其他執行緒取得鎖、設定旗標 |
| 定時休眠 | 解鎖 → sleep_for(100ms) → 再上鎖 |
休眠時間難拿捏:太短照樣浪費 CPU、太長反應延遲(遊戲丟幀) |
| 條件變數 | wait() 阻塞,由另一執行緒 notify 喚醒 |
首選:不耗 CPU、事件發生立即喚醒 |
定時休眠版的核心模式(休眠前必須解鎖,讓別的執行緒有機會設定旗標):
std::unique_lock<std::mutex> lk(m);
while (!flag) {
lk.unlock(); // 1 解鎖
std::this_thread::sleep_formilliseconds(100); // 2 休眠
lk.lock(); // 3 再上鎖
}
std::condition_variable 基本用法
兩種實作都宣告於 <condition_variable>,且都必須與互斥鎖一起工作。典型生產者/消費者模式(代碼 4.1):
std::mutex mut;
std::queue<data_chunk> data_queue;
std::condition_variable data_cond;
// 生產者:
std::lock_guard<std::mutex> lk(mut);
data_queue.push(data);
data_cond.notify_one(); // 通知等待者
// 消費者:
std::unique_lock<std::mutex> lk(mut);
data_cond.wait(lk, []{ return !data_queue.empty(); }); // 帶 predicate 等待
wait()先檢查 predicate:為true立即返回(仍持鎖);為false則解鎖互斥鎖並阻塞。- 被
notify_one()喚醒後,重新上鎖再檢查條件;不滿足就再度解鎖等待。 - 任意函式或可調用物件皆可作 predicate,不限 Lambda。
生產者執行緒 消費者執行緒
│ │
prepare_data() unique_lock(mut) 上鎖
│ wait(lk, pred)
lock_guard(mut) 上鎖 ├─ pred==false → 解鎖並阻塞
push(data) 入佇列 │
notify_one() ────── 喚醒 ─────────> │
(解鎖) ├─ 重新上鎖 → 再查 pred
└─ pred==true → 返回(持鎖)
取出資料 → 解鎖 → process(data)
std::unique_lock 而非 std::lock_guard
等待中的執行緒必須在等待期間解鎖互斥鎖、喚醒後重新上鎖,std::lock_guard 沒有這種彈性。若休眠期間仍持鎖,生產者永遠無法上鎖推入資料,消費者也永遠等不到條件達成。unique_lock 的彈性也適用於處理資料前提早 unlock()——耗時操作不應長時間持鎖。
虛假喚醒 (spurious wakeup) 與 predicate
等待執行緒可能並非回應 notify 而醒來(重新取得鎖並檢查條件),這稱為虛假喚醒。其次數與頻率不確定,所以:
wait(lk, pred)過程中 predicate 可能被呼叫任意多次 → 不建議使用有副作用的函式做條件檢查。- 本質上
wait()是「忙碌-等待」的最佳化,最小化等價實作:
template<typename Predicate>
void minimal_waitmutex>& lk, Predicate pred {
while (!pred()) { lk.unlock(); lk.lock(); }
}
notify_one vs notify_all
| 成員函式 | 喚醒對象 | 適用情境 |
|---|---|---|
notify_one() |
至多一個等待執行緒(不保證是哪個) | 工作佇列:一份資料只需一個執行緒處理 |
notify_all() |
所有等待執行緒 | 全體都要回應:共享資料初始化完成、週期性重新初始化 (periodic reinitialization) |
使用 notify_one() 時不保證特定執行緒被通知到——即使只有一個等待執行緒,它也可能正在處理資料而錯過通知。而且若條件只成立一次(等待執行緒只等這麼一次,之後不再等),條件變數並非最佳選擇,應改用 future(見 04-Synchronizing-Operations/02-Futures-and-Asynchronous-Tasks)。
condition_variable vs condition_variable_any
std::condition_variable |
std::condition_variable_any |
|
|---|---|---|
| 可搭配的鎖 | 僅 std::unique_lock<std::mutex> |
任何滿足最低要求(可 lock/unlock)的鎖型別 |
| 開銷 | 較小 → 首選 | 體積、效能、系統資源開銷較大 |
| 使用時機 | 一般情況 | 對鎖的靈活性有特殊要求時 |
執行緒安全佇列 threadsafe_queue
如同 3.2.3 節的執行緒安全堆疊,std::queue 的介面存在介面競爭條件:front() 與 pop() 分離,兩個執行緒可能讀到同一元素卻各自 pop。因此設計時將兩者合併為單一操作,並依「等不等」提供兩組 pop:
| 操作 | 行為 | 兩種重載 |
|---|---|---|
try_pop() |
立即返回,即使佇列為空 | bool try_pop(T&)(無資料回傳 false)/ std::shared_ptr<T> try_pop()(無資料回傳空指標) |
wait_and_pop() |
等待到有值可取才返回 | void wait_and_pop(T&) / std::shared_ptr<T> wait_and_pop() |
push() |
上鎖 → 入佇列 → notify_one() |
— |
empty() |
const 成員函式,仍需上鎖 |
— |
template<typename T>
class threadsafe_queue {
mutable std::mutex mut; // const 成員函式也要上鎖 → mutable
std::queue<T> data_queue;
std::condition_variable data_cond;
public:
void push(T v) {
std::lock_guard<std::mutex> lk(mut);
data_queue.push(v);
data_cond.notify_one();
}
void wait_and_pop(T& v) {
std::unique_lock<std::mutex> lk(mut);
data_cond.wait(lk, [this]{ return !data_queue.empty(); });
v = data_queue.front(); data_queue.pop();
}
};
mutable std::mutex:empty()是 const、拷貝建構函式的參數是 const 引用,但「上鎖」是可變操作,所以互斥鎖必須宣告為mutable才能在其中上鎖。- 拷貝建構函式須先鎖住
other.mut再複製底層佇列;禁用簡單賦值。
互斥鎖與條件變數都是佇列的私有成員,呼叫端 push() / wait_and_pop() 不需任何外部同步,同步問題與競爭條件出現的機率大幅降低。這是第 6 章鎖基礎資料結構設計的起點。
Exam/Test Patterns
| 情境/關鍵字 | 答案 |
|---|---|
| 「等待事件但不想浪費 CPU」 | std::condition_variable::wait + predicate |
| 「wait 為何要用 unique_lock」 | 等待期間需解鎖、喚醒後重新上鎖;lock_guard 不支援 |
| 「沒有 notify 也醒來了」 | 虛假喚醒 → 用 predicate 重查條件;predicate 不可有副作用 |
| 「front() 與 pop() 分開呼叫」 | 介面競爭條件 → 合併為 try_pop / wait_and_pop |
| 「const 成員函式內要上鎖」 | 互斥鎖宣告為 mutable |
| 「所有等待執行緒都要被喚醒」 | notify_all()(初始化完成、週期性重新初始化) |
| 「一次性事件、只等一次」 | 條件變數非最佳 → 改用 future |
| 「需要搭配自訂鎖型別的條件變數」 | std::condition_variable_any(開銷較大) |
Related Notes
- 04-Synchronizing-Operations/02-Futures-and-Asynchronous-Tasks
- 04-Synchronizing-Operations/03-Waiting-with-Time-Limits
- 04-Synchronizing-Operations/04-Functional-and-Message-Passing-Styles
- 04-Synchronizing-Operations/05-Continuations-Latches-and-Barriers
- 04-Synchronizing-Operations/Practice-Synchronizing-Operations
- 03-Sharing-Data/01-Race-Conditions-and-Mutexes
- 06-Lock-Based-Data-Structures/02-Thread-Safe-Stack-and-Queue