Item 39:考慮以 void futures 進行一次性事件通訊 (Consider void futures for one-shot event communication)
Overview Table
| 方案 | 需要 mutex | 通知先於等待仍正確 | 免疫 spurious wakeup | 真正阻塞(不輪詢) | 可重複使用 | 主要缺點 |
|---|---|---|---|---|---|---|
| condvar(條件變數) | 需要(多餘的) | 否(會永久等待) | 否 | 是 | 是 | mutex 多餘、進度順序限制、需自行驗證事件 |
| atomic flag(輪詢旗標) | 不需要 | 是 | 是 | 否(polling) | 是 | 佔用硬體執行緒、context switch、耗電 |
| condvar + flag | 需要 | 是 | 是 | 是 | 是 | 設計「彆扭」(stilted):雙重通知機制 |
std::promise<void> + future |
不需要 | 是 | 是 | 是 | 否(one-shot) | shared state 用 heap 配置、只能設定一次 |
情境:偵測任務(detecting task)通知反應任務(reacting task)「某事件已發生」——例如資料結構初始化完成、某計算階段結束、感測器數值出現。
為何 condvar 與 flag 方案都不理想
條件變數方案的三個問題
std::condition_variable cv; // 事件用的 condvar
std::mutex m; // 搭配 cv 的 mutex
// --- 偵測任務 ---
// ... 偵測到事件
cv.notify_one(); // 通知反應任務(多個則用 notify_all)
// --- 反應任務 ---
{
std::unique_lock<std::mutex> lk(m); // 必須先鎖 mutex(C++11 API 規定)
cv.wait(lk); // 等待通知——這樣寫並不正確!
// ... 對事件做出反應(m 仍鎖住)
}
| 問題 | 說明 |
|---|---|
| 多餘的 mutex(code smell) | mutex 是保護共享資料用的;若兩任務靠程式邏輯天然互不干擾(如「初始化完才交棒」),根本不需要 mutex |
| 通知遺失(lost wakeup) | 若偵測任務在反應任務 wait 之前就 notify,反應任務會永遠等待——即對兩任務的相對進度施加了限制 |
| spurious wakeup(虛假喚醒) | 未被 notify 也可能被喚醒;正解是 cv.wait(lk, []{ return 事件是否發生; }),但反應任務可能根本無法自行判斷事件是否發生(否則它就不用等 condvar 了) |
輪詢旗標:正確但昂貴
std::atomic<bool> flag(false); // 共享旗標(見 Item 40)
// --- 偵測任務 ---
flag = true; // 告知反應任務
// --- 反應任務 ---
while (!flag); // 輪詢等待事件——沒有阻塞!
- 沒有 condvar 的三個問題,但反應任務「看似阻塞、實則執行中」:佔用硬體執行緒、每個時間片都有 context switch 成本、讓本可休眠省電的核心持續運轉。condvar 方案中
wait是真正阻塞,這正是它的優勢。
condvar + flag 組合:能用但彆扭
std::condition_variable cv;
std::mutex m;
bool flag(false); // 有 mutex 保護,「不必」是 std::atomic(Item 40)
// --- 偵測任務 ---
{
std::lock_guard<std::mutex> g(m); // 鎖住 m
flag = true; // 告知(第 1 部分)
} // 解鎖 m
cv.notify_one(); // 告知(第 2 部分)
// --- 反應任務 ---
{
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [] { return flag; }); // lambda 防 spurious wakeup
// ... 反應
}
- 無論等待與通知先後順序都正確、免疫虛假喚醒、不輪詢——但通訊方式彆扭(stilted):notify 只說「事件可能發生了」,flag 才說「確定發生了」,偵測任務得做兩件事、反應任務得查兩處。
void future:乾淨的一次性通道
核心想法:讓反應任務等待一個由偵測任務設定的 future。Item 38 指出 promise–future 通道不限於 callee→caller,可用於程式中任兩點間傳遞資訊。事件通訊不需傳資料,故型別參數用 void。
std::promise<void> p; // 通訊通道的寫入端
// --- 偵測任務 ---
p.set_value(); // 「寫入」void 資料 → 告知反應任務
// --- 反應任務 ---
p.get_future().wait(); // 在對應的 future 上等待(真正阻塞)
// ... 反應
偵測任務 shared state 反應任務
+-----------+ set_value() +--------------+ wait() +-------------+
| promise | -------------> | (heap 配置, | <---------- | future / |
| <void> | 一次性寫入 | 一次性) | 真正阻塞 | shared_ |
+-----------+ +--------------+ | future<void>|
+-------------+
| 特性 | 結果 |
|---|---|
| mutex | 不需要 |
| set 先於 wait | 仍正確(狀態保存在 shared state) |
| spurious wakeup | 免疫(只有 condvar 有此問題) |
| 等待方式 | 真正阻塞,不耗系統資源 |
| 代價 1 | promise 與 future 之間的 shared state 通常在 heap 上動態配置 |
| 代價 2 | std::promise 只能 set 一次——one-shot 機制,不能重複使用(condvar 可重複 notify、flag 可清除再設) |
應用:建立「暫停中」的執行緒(單個與多個)
用途:先建好執行緒(吞下建立開銷)、或在執行前先設定 priority / core affinity(透過 native_handle 使用底層 API),之後再放行。
std::promise<void> p;
void react(); // 反應任務的函式
void detect() // 偵測任務的函式
{
std::thread t([] // 建立執行緒
{
p.get_future().wait(); // 暫停 t,直到 future 被設定
react();
});
// ... 此處 t 處於暫停狀態(尚未呼叫 react)
p.set_value(); // 解除暫停(進而呼叫 react)
// ... 其他工作
t.join(); // 使 t 成為 unjoinable(見 Item 37)
}
ThreadRAII + 暫停執行緒 = 懸掛風險
若改用 Item 37 的 ThreadRAII(dtor 設為 join)包住這條執行緒,且在 p.set_value() 之前的程式碼拋出例外,則 set_value 永遠不會被呼叫 → lambda 中的 wait 永不返回 → 執行緒永不結束 → ThreadRAII 解構子的 join 永遠等不到,函式懸掛(hang)。原始碼版本同樣有「set_value 前拋例外則 detect 懸掛」的問題,Meyers 將解法留作習題。
擴展至多個反應任務:改用 std::shared_future<void>。std::future::share() 會把 shared state 的所有權轉移給產生的 std::shared_future;每條反應執行緒需要自己的一份拷貝,故 lambda 以傳值捕獲 sf:
std::promise<void> p;
void detect() // 多個反應任務版本
{
auto sf = p.get_future().share(); // sf 型別為 std::shared_future<void>
std::vector<std::thread> vt; // 反應執行緒容器
for (int i = 0; i < threadsToRun; ++i) {
vt.emplace_back([sf]{ sf.wait(); // 傳值捕獲:各執行緒等待自己的
react(); }); // shared_future 拷貝(emplace_back 見 Item 42)
}
// ... 若此處拋出例外,detect 一樣會懸掛!
p.set_value(); // 一次解除所有執行緒的暫停
for (auto& t : vt) { // 使所有執行緒 unjoinable
t.join();
}
}
Exam/Test Patterns
| 情境關鍵字 | 答案 |
|---|---|
| condvar 通知在 wait 之前發生 | 反應任務錯過通知、永久等待(進度順序限制) |
| 「未 notify 也被喚醒」 | spurious wakeup;用 cv.wait(lk, lambda) 檢查條件防範 |
| 事件通訊卻被迫用 mutex | condvar 方案的 code smell:mutex 多餘 |
while (!flag); 等待 |
輪詢:非真正阻塞,佔硬體執行緒、context switch、耗電 |
| condvar + bool flag 組合 | 正確但彆扭;flag 受 mutex 保護故不需 atomic(Item 40) |
| 事件通訊不需傳資料 | std::promise<void> + std::future<void>(void future) |
| promise/future 方案的兩個代價 | heap 配置 shared state、one-shot(只能 set 一次) |
| 只有哪種機制怕 spurious wakeup | 只有 condvar;flag 與 future 皆免疫 |
| 建立暫停狀態的執行緒、設定 priority/affinity | lambda 內 p.get_future().wait() 暫停;native_handle 用底層 API 設定 |
| set_value 前拋例外 + RAII join | wait 永不返回 → join 永不完成 → 函式懸掛 |
| 一個事件喚醒多條執行緒 | share() 取得 std::shared_future<void>,各 lambda 傳值捕獲自己的拷貝 |
Related Notes
- 08-Concurrency-API/04-Thread-Handle-Destructor-Behavior — Item 38:promise–future 之間的 shared state 與通訊通道概念,是本 Item 的基礎
- 08-Concurrency-API/03-Threads-Unjoinable-On-All-Paths — Item 37:
join()使執行緒 unjoinable;ThreadRAII 與暫停執行緒併用的懸掛風險 - 08-Concurrency-API/06-Atomic-Vs-Volatile — Item 40:
std::atomic<bool>旗標;mutex 保護下 flag 不需 atomic 的理由 - 08-Concurrency-API/01-Task-Based-Vs-Thread-Based — Item 35:task 與 thread 的取捨背景
- 09-Tweaks/02-Emplacement — Item 42:多執行緒範例中
vt.emplace_back的原理 - 07-Lambda-Expressions/01-Avoid-Default-Capture-Modes — Item 31:lambda 捕獲模式;本 Item 中
[sf]傳值捕獲的重要性