1. Worker 可能會無限期等待
如果你已經寫過一次 consumer,並且它誠實地執行 cv.wait(lock, []{ return !q.empty(); }), 你非常接近成功……但還沒能讓程式結束。
問題在於,wait 中的等待可能是永久的,這不是 condition_variable 的錯誤: 線程正做你要求它做的事——等待,直到有工作可做。 如果工作永遠不會再次出現,該線程就會一直等待到世界末日(或者到你的 IDE 終止會話,那通常會先發生)。
設想這種情形:producer 已經完成工作、所有任務都已加入、佇列清空、consumer 睡著並在等待。 程式想要退出,但對 worker 的 join() 卡住了,因為 worker «睡着» 並不會自行醒來。 這是非常常見的「第一個多線程死鎖」,通常表現為: 「一切看起來都正常……但應用程序有時無法關閉」。
迷你反例(它可能永遠阻塞):
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
int pop_blocking_forever() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return !q.empty(); }); // 如果再也沒有人 push — 就會永遠等待
int x = q.front();
q.pop();
return x;
}
這裡沒有「同步錯誤」——而是缺少一個 結束協定。接下來我們就來添加這個協定。
2. 佇列狀態:把「已關閉」作為協定的一部分
想法:加入一個「不會再有新任務」的旗標
為了正確結束 worker,我們需要在受 mutex 保護的共用狀態中增加一個事實: producer 將永遠不會再加入任務。 通常這是一個布林旗標,稱為 closed、 done、stop、shutdown 等。
關鍵思想是:consumer 應該在「有工作」之外也能被喚醒,當發生「永遠不會再有工作」時也要喚醒。 這是兩個不同的事件,但在我們的程式碼中它們透過同一種方式表達: 受保護狀態改變了,意味著要喚醒等待者以便他們重新檢查謂詞。
可以用下圖示意佇列的狀態變化:
stateDiagram-v2
[*] --> OpenEmpty: start
OpenEmpty --> OpenNonEmpty: producer push
OpenNonEmpty --> OpenEmpty: consumer pop
OpenEmpty --> ClosedEmpty: close()
OpenNonEmpty --> ClosedNonEmpty: close()
ClosedNonEmpty --> ClosedEmpty: consumer pop (until empty)
ClosedEmpty --> [*]: consumer exits
這裡重要的是「已關閉」並不等於「佇列消失」。它只是表示「不會再有新任務」。 既有任務(如果有)仍然可以(且通常應該)被處理。
正確的等待謂詞:closed || !q.empty()
在整個 shutdown protocol 中最重要的地方就是等待謂詞。 它決定了 consumer 何時被喚醒,以及它認為哪些情況是「足以」從睡眠中醒來的理由。 如果謂詞不正確,你會遇到阻塞、過早退出導致任務遺失,或嘗試在空佇列上調用 front() 等問題。
通常正確的等待形式是:我們等待,直到(佇列非空) 或(佇列被關閉)。 這可以理解為:「如果可以做點什麼就醒來——要麼取一個任務,要麼乾淨地結束」。
cv.wait(lock, [] { return closed || !q.empty(); });
注意:這必須是 wait(lock, predicate),而不是 wait(lock) 或者 if (...) wait(lock) 的組合。 喚醒可能是虛假的,而通知並不是「事件隊列中的單個事件」,而只是「去重新檢查狀態」的信號。
當 wait 返回後,consumer 必須小心地判斷到底發生了什麼。 可能的情況包括:佇列非空——很好,取出任務; 佇列為空但 closed == true——這是合法的「工作結束」情形,應當退出。
如何關閉佇列:在 mutex 下設置 closed 並呼叫 notify_all
關閉操作通常由兩步組成,順序比看起來重要得多。
首先,在保護佇列的同一 mutex 下,我們將旗標 closed 設為 true。 這一點很關鍵:closed 是受保護狀態的一部分,與佇列同屬一個互斥保護範圍, 否則你會失去一致性保證。
然後我們喚醒等待者。這裡通常需要使用 notify_all(), 因為可能有多個等待的 consumer,所有人都應該被喚醒以見到 closed 並正確地結束。 即使只有一個 consumer,使用 notify_all() 也不會有害; 但在多 consumer 的情況下 notify_one() 可能導致有人永遠睡著。
關閉的簡單示例:
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
bool closed = false;
void close_queue() {
{
std::lock_guard<std::mutex> lock(m);
closed = true;
}
cv.notify_all(); // 喚醒所有人:有人需要從 wait 中退出
}
為什麼通常在退出 lock_guard 之後才呼叫 notify_all()? 這並非魔法也不是「比較漂亮」。只是減少了 mutex 被持有的時間,並給予被喚醒的線程更快獲取 mutex 並繼續執行的機會。 在持鎖時通知是合法的,但通常效率較低,在高負載時可能表現較差。
3. Consumer 邏輯:把「已關閉且為空」視為停止信號
wait 之後的退出規則
現在我們組裝 shutdown protocol 的「核心」:consumer 在 wait 之後執行的邏輯。 正是在此處決定 worker thread 的命運:繼續工作、正確結束,還是不小心嘗試從空佇列取出元素。
典型範式如下:
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
bool closed = false;
bool try_pop(int& out) {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return closed || !q.empty(); });
if (q.empty() && closed) {
return false; // 信號:不會再有任務
}
out = q.front();
q.pop();
return true;
}
這裡 false 表示「已關閉且為空」,也就是「可以從 worker loop 中退出」。
此時通常會出現新手的疑問:「為何不能在 closed == true 時立刻退出?」 因為 closed 表示「不會再有新任務」,但 佇列中現有的任務可能仍存在。 如果你在看到 closed 時就直接退出,會丟失那些在關閉之前已經放入佇列的任務。
std::optional 作為更乾淨的信號
回傳 bool 加上輸出參數是一種可行方案,但有點「帶著 C 的味道」。 使用 std::optional<T> 能很好表達「有值 / 無值」:pop() 要麼回傳任務, 要麼回傳 std::nullopt,表示佇列已關閉且為空。
好處包括:不需要「魔術值」例如 -1 (「如果合法任務就是 -1 該怎麼辦?」),也不需要用可空指標等變通方法。
簡化版本:
#include <condition_variable>
#include <mutex>
#include <optional>
#include <queue>
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
bool closed = false;
std::optional<int> pop() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return closed || !q.empty(); });
if (q.empty()) {
return std::nullopt; // 已關閉且為空
}
int x = q.front();
q.pop();
return x;
}
請注意一個細節:在 wait 之後我們只檢查 if (q.empty()) 即可。為什麼這樣就夠了? 因為 wait 只會在 (closed || !q.empty()) 為真時返回。 如果佇列為空,那唯一的可能原因是 closed == true。 換句話說,這裡的「空」等價於「已關閉且為空」,使得程式更簡潔。
4. 封裝與範例:TaskQueue + worker 迴圈
為何最好把協定封裝成一個類別
當 mutex、cv、 queue 與 closed 作為獨立全域變數存在時, 你必須在腦中維持紀律。 當你累了、或加入第二個檔案、或讓同事「稍微改一點」時,問題就會出現。 之後程式開始自行演化:有人在沒有 mutex 的情況下讀取 closed, 有人在關閉時使用 notify_one(),有人在 close 之後仍然 push,然後感到困惑。
因此一個實用的步驟是把協定包裝到一個小類別中。 這並不是為了 OOP 而 OOP,而是為了「讓規則只在一個地方維護」。
TaskQueue 的骨架(只包含必需部分):
#include <condition_variable>
#include <mutex>
#include <optional>
#include <queue>
class TaskQueue {
public:
void push(int x) {
{
std::lock_guard<std::mutex> lock(m_);
q_.push(x);
}
cv_.notify_one();
}
std::optional<int> pop() {
std::unique_lock<std::mutex> lock(m_);
cv_.wait(lock, [&] { return closed_ || !q_.empty(); });
if (q_.empty()) return std::nullopt;
int x = q_.front();
q_.pop();
return x;
}
void close() {
{
std::lock_guard<std::mutex> lock(m_);
closed_ = true;
}
cv_.notify_all();
}
private:
std::mutex m_;
std::condition_variable cv_;
std::queue<int> q_;
bool closed_ = false;
};
注意謂詞的捕獲:[&]。 這是可以的,因為謂詞在持有 m_ 的情況下被調用,且只讀取物件的成員。 此處謂詞是「純」的:它不會改變狀態,只做檢查。 這一點很重要,因為 wait(lock, predicate) 可能會多次呼叫該謂詞。
那麼是否還能在 close 之後執行 push? 技術上可以——目前的程式碼沒有禁止這樣做。 但作為專案政策通常會禁止:關閉後不接受新任務。 我們可以加入檢查來防止,但此處不作複雜化:本講重點是 worker 終止協定。
Worker 迴圈:只要 pop() 回傳任務就繼續工作
現在實作一個「典型」的 worker:該線程不斷從佇列取出任務並處理,直到 pop() 回傳 nullopt 為止。 重要的是處理工作發生在臨界區之外,因為 pop() 返回的是任務的副本(這裡是 int),而 TaskQueue 內部的 mutex 已經釋放。
範例 worker 函式:
#include <iostream>
#include <optional>
void worker_main(TaskQueue& tasks) {
while (true) {
std::optional<int> task = tasks.pop();
if (!task) break;
std::cout << "Processing task " << *task << '\n';
// Processing task 1
// Processing task 2
}
std::cout << "Worker stopped\n"; // Worker stopped
}
這裡的結束條件非常誠實:「沒有任務且不會再有任務」。
完整迷你應用:producer → close() → join()
組裝一個小 demo,展示從頭到尾的 shutdown protocol。 這不是執行緒池或任務調度器:只有一個 producer(main)和一個 consumer(worker)。
#include <chrono>
#include <iostream>
#include <thread>
int main() {
TaskQueue tasks;
std::thread worker([&] {
worker_main(tasks);
});
tasks.push(1);
tasks.push(2);
std::this_thread::sleep_for(std::chrono::milliseconds{50});
tasks.close(); // 重要:要關閉,否則 worker 可能會永遠等待
worker.join(); // 正確結束
std::cout << "Main done\n"; // Main done
}
語義上重要的是這些點(非純理論): close() 是那個「正式的結束」:producer 告知 consumer 不會再有新任務。 notify_all() 在 close() 內部保證,即使 worker 正在 wait 中也會被喚醒。 接著 pop() 會回傳 nullopt, worker 正確地從迴圈中退出。
如果移除 tasks.close(),很可能會得到無限等待,而 join() 就會卡住。 因此 close() 並不是「可選按鈕」,而是生命週期協定的一部分。
5. 替代方案:為何 poison pill 通常較差
有時人們會用「特殊任務」(poison pill)替代旗標 closed: 例如往佇列放入 -1,表示「停止」。 這在任務類型是 int 且沒有人會發送合法的 -1 時可能管用。 但設計上很脆弱。
簡要比較:
| 方法 | stop 看起來像 | 優點 | 缺點 |
|---|---|---|---|
| Poison pill (特殊值) | |
在教學範例中快速撰寫 | 需要「禁止」的值範圍;對真實任務類型擴展性差 |
| 旗標 closed | |
明確表達佇列狀態模型 | 需要仔細設計謂詞與退出邏輯 |
| optional 在 pop() | |
在 worker 迴圈中非常易讀 | 在佇列實作中需要多一點程式碼(但通常是值得的) |
在本講中我們選擇 closed + optional, 因為它直接表達「沒有更多值」的語意。 你也不需要向團隊解釋為何 -1 突然代表「世界末日」。
6. 常見錯誤
錯誤 #1:關閉佇列但忘了呼叫 notify_all()。
這是典型情況:你把 closed 設為 = true,心裡很滿足,但 worker 仍舊掛在 wait。 原因很簡單:wait 本身不會因為「某處的 bool 被改了」而自動醒來。 它會在被喚醒(notify_*)或發生虛假喚醒時醒來(但不應該依賴後者)。 因此關閉操作總是「在 mutex 下改變狀態」然後「喚醒等待者」,而在 shutdown 時通常要使用 notify_all()。
錯誤 #2:在多個 consumers 的情況下,shutdown 使用 notify_one()。
如果有兩個 worker,而你只喚醒其中一個,另外一個可能永遠睡著。 即便看起來「他總有一天會醒來」,那不是一個協定,而是希望。 在 shutdown 時應喚醒所有人,好讓每個人都看見 closed 並決定是否退出。
錯誤 #3:在保護佇列的同一 mutex 之外讀寫 closed。
closed 是與佇列同屬一個狀態集合的一部分。 如果你用一把 mutex 保護 q,卻在沒有鎖的情況下修改 closed, 就破壞了對狀態一致性的保護。 結果謂詞可能會看到「舊的」世界圖景,而程式行為會依賴於時序的巧合。 即便「在我的電腦上看起來可行」,你只是還沒遇到會出問題的時序條件。
錯誤 #4:等待謂詞只檢查 !q.empty()。
這樣的 consumer 不知道如何結束。 它會永遠等待「任務出現」,然後 join() 卡住。 謂詞必須包含結束條件: closed || !q.empty()。
錯誤 #5:在看到 closed == true 後立即從 worker 迴圈退出,導致任務遺失。
如果你在看到 closed 時立刻退出,就會丟掉那些已在佇列中的任務。 通常正確的規則是:只要佇列不為空就繼續處理;當佇列為空且已關閉時再退出。 這也是為什麼在 wait 之後檢查「如果空」就足夠的原因——空意味著同時「已關閉且為空」。
GO TO FULL VERSION