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 + 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
    // ... 反應
}

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 stateone-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 傳值捕獲自己的拷貝