CodeGym /課程 /C++ SELF /Shutdown protocol — 如何停止 worker thread

Shutdown protocol — 如何停止 worker thread

C++ SELF
等級 70, 課堂 4
開放

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 (特殊值)
q.push(-1)
在教學範例中快速撰寫 需要「禁止」的值範圍;對真實任務類型擴展性差
旗標 closed
closed = true; notify_all()
明確表達佇列狀態模型 需要仔細設計謂詞與退出邏輯
optional 在 pop()
return nullopt;
在 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 之後檢查「如果空」就足夠的原因——空意味著同時「已關閉且為空」。

1
任務
C++ SELF, 等級 70, 課堂 4
上鎖
關閉的佇列
關閉的佇列
1
任務
C++ SELF, 等級 70, 課堂 4
上鎖
將佇列封裝為類別
將佇列封裝為類別
1
任務
C++ SELF, 等級 70, 課堂 4
上鎖
Brigada workers
Brigada workers
1
任務
C++ SELF, 等級 70, 課堂 4
上鎖
命令生產者
命令生產者
1
問卷/小測驗
condition_variable,等級 70,課堂 4
未開放
condition_variable
condition_variable
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION