多執行緒實務最佳實踐 C++ 篇 ── 以 RAII 與 jthread 從結構上消除事故

· · Windows, 多執行緒, C++, Visual Studio, 業務應用程式, 缺陷調查, 設計

更新紀錄(僅初版,2026年08月02日 發布)
初次發布
引用本文(DOI(已登錄存檔): 10.5281/zenodo.22175881)

以下 DOI 指向先前登錄的存檔,內容可能與目前正文不同。引用目前正文時,請使用本頁網址。

Go Komura(2026)。〈多執行緒實務最佳實踐 C++ 篇 ── 以 RAII 與 jthread 從結構上消除事故〉。小村軟體有限公司。 https://comcomponent.com/zh-TW/blog/multithreading-best-practices-cpp/

DOI(已登錄存檔)
10.5281/zenodo.22175881
DOI(上次登錄版本)
10.5281/zenodo.22175882

「一拋出例外,std::thread 的收尾就把整個應用程式一起結束掉了」「停止旗標已經立起來,工作者執行緒卻回不來」「把在 C# 上運作正常的設計搬到 C++,就會偶爾當掉」。在 C++ 的多執行緒裡,把工作並排展開之前,必須先決定共享資料要怎麼保護、執行緒要怎麼結束

尤其在 C++ 中,資料競爭不是單純算錯了值,而是未定義行為(undefined behavior)。碰巧跑出正確的結果,不能拿來當作安全性的依據。1

本文是多執行緒實務系列的 C++ 篇,對象是以現代 C++(C++17/20)撰寫業務應用程式、裝置控制與 DLL 的開發者。整理的順序是選擇執行方式 → 減少共享 → 實作同步與停止 → 確認 Windows 特有的限制與驗證方式。只適用於 C++20 的範例,會在該處特別標示。

把相同原則在其他語言上展開的「.NET 篇」「C 語言篇」「Java 篇」也有,不過本文單獨閱讀也沒問題。

1. 先講結論 ── 在「加上同步」之前,先決定共享與生命週期

首先減少共享可變狀態,剩下的共享交給標準函式庫與 RAII 保護。接著,把從停止要求到 join 完成串成同一套設計。1

決定的順序 要判斷的事 本文的參照處
1. 執行方式 是單次的任務、對集合的平行處理,還是長時間運作的工作者執行緒。是不是想用增加執行緒來解決 I/O 等待 第 3 章
2. 資料的歸屬 能不能靠分割、傳值、不可變化、佇列來減少對共享資料的寫入 第 4 章
3. 剩餘共享的保護 哪些資料由哪個 mutex 保護。是否區分了用 atomic 處理的單一變數更新,與多個變數之間的一致性 第 5 章
4. 結束的步驟 停止要求能否同時送達等待中與處理中的執行緒。能否記錄例外,並在最後 join 第 6 章
5. 部署位置與驗證 是否遵守 DLL、UI、COM 的限制,能否用日誌、傾印檔、壓力測試來調查 第 7〜8 章

工具的預設選擇是:執行緒的生命週期用 std::jthread、鎖用 RAII、等待用帶述詞的 wait、單一的共享旗標或計數器用 std::atomicvolatile 不用於同步。C++20 之前的停止設計,用 atomic 旗標與條件變數組出來。2345

不過,只是選好工具還不算完成。即使 jthread 會自動 join,只要處理本身無法結束,它就會一直等下去。即使用 atomic 替換了指標,也守不住舊物件的生命週期。這兩點請搭配後面的程式碼範例一起確認。

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 27 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

2. 先要理解的三個前提 ── 競爭、互相等待與 RAII

2.1. 競爭狀態取決於執行順序,而 C++ 的資料競爭會變成未定義行為

競爭狀態(race condition)是指結果會隨著多個執行緒到達處理的先後順序而改變的臭蟲。把共享計數器的 ++count 拆成「讀取 → 加算 → 寫回」來看,就能明白兩個執行緒讀到同一個值、其中一方的更新被抹掉是怎麼發生的。

共享計數器的更新遺失的例子示意兩個執行緒讀到同一個值,各自加算後寫回,於是更新被遺失。count 是 10A 和 B 都讀到 10各自在本地加到 11A 寫回 11B 也寫回 11在 C++ 中結果不限於此

圖1:這是加算遺失的示意圖,並不表示 C++ 未定義行為的結果僅限於此。

這只是用來理解競爭的示意圖。在 C++ 中,多個執行緒處理同一個非 atomic 的記憶體位置,其中至少有一方是寫入,而又缺少必要的同步時,按規格就構成資料競爭造成的未定義行為。實際的結果並不會被限定成「加算消失一次」而已。1

編譯器是以不存在資料競爭為前提進行最佳化的。因此,包含條件判斷與讀寫的重新排序、合併在內,從原始碼所期待的行為都無法再獲得保證。不能認為「讀到的不是舊值就是新值」。把 volatile bool 當成停止旗標,在 C++ 中也構不成執行緒間的同步,所以解決不了這個問題。15

2.2. 死結源於「等待的對象」連成了環

死結是雙方都在等對方釋放手上的鎖,結果誰都無法繼續前進的狀態。只要執行緒 A 持有鎖 1 並等待鎖 2,執行緒 B 持有鎖 2 並等待鎖 1,它就成立了。

兩把鎖的循環等待互相等待對方持有的鎖,兩邊都無法繼續前進。等待鎖 2等待鎖 1A 持有鎖 1B 持有鎖 2

圖2:等待的箭頭連成環之後,雙方就停在等對方前進的狀態。

不論是競爭還是死結,出問題的執行順序都可能在開發機上不出現,卻在核心數與負載不同的客戶現場出現。接上偵錯器或加上日誌會改變時序,導致無法重現。所以,在正確地做同步之前,先減少需要同步的地方才是關鍵。

2.3. 用 RAII 把包含例外路徑在內的收尾變成結構

C++ 沒有 finally,取而代之的是把物件生命週期與資源管理綁在一起的 RAII(Resource Acquisition Is Initialization)。形式是:取得了鎖的物件離開作用域時釋放鎖,擁有執行緒的物件被銷毀時完成合流。1

不只是正常結束,提前 return 與因例外離開作用域的路徑,也都掛到同一套收尾上。後面的 jthread 與鎖包裝器,當成這個想法的實作來讀,就比較容易理解它們該如何區分使用。

3. 選擇執行方式 ── 先用任務,必要時才用執行緒

3.1. 區分單次的工作、對集合的處理與 I/O 等待

「不要自己增加執行緒」這個原則,在 C++ 中同樣成立。先看工作的形態,再選擇表達它的工具。

工作的形態 主要選項 首先要確認的事
單次的非同步處理與接收結果 std::asyncstd::future 啟動策略與 future 的生命週期
對集合的平行處理 PPL、C++17 的平行演算法 單次迭代的工作量與例外的處理
具有生命週期的工作者執行緒 C++20 的 std::jthread 在哪裡觀測停止要求,以及 join
I/O 等待 在 Windows 上是 OVERLAPPED I/O 或 IOCP 使用非同步 I/O,而不是增加執行緒
先選執行方式,再決定生命週期選擇與工作形態相符的執行方式,並依該方式決定結果與停止的管理方式。單次、集合處理長時間運作的處理等待 I/O確認工作的形態要執行什麼任務或平行演算法管理工作者執行緒的生命週期考慮非同步 I/O設計結果、例外與結束

圖3:在增加執行緒之前,先決定工作的形態,以及由誰來管理結束。

不要只為了等待 I/O 就增加執行緒。在 Windows 原生程式碼中承接這類需求的 OVERLAPPED I/O 與 IOCP,在「Windows I/O 的深層 第 2 回」中討論過。

3.2. async 要把「開始的條件」與「持有結果的期間」成套決定

std::async 很省事,但不能把回傳的 future 丟掉。有兩點需要注意。6

第一點是銷毀時的等待。std::launch::async 啟動的工作尚未完成時,若銷毀了最後持有其共享狀態的 future 或 shared_future,就會發生完成等待。把回傳值當場丟掉,即使本意是非同步,也會因為在該運算式結束處等待,而變成與序列執行相同的形態。

第二點是延遲執行。 不指定啟動策略時,實作可能會選擇 deferred。這種情況下,只要沒有人呼叫 get()wait(),工作就不會被執行。確實想在另一個執行緒上執行的地方,要明確寫出 std::launch::async,並決定由誰持有 future、持有到什麼時候。

async 的啟動方針與 future 的生命週期區分以 async 啟動時銷毀處的等待,以及 deferred 下不等待就不會執行的情況。asyncdeferred呼叫 std::async選中的啟動方針在另一個執行緒上執行銷毀最後一個 future未完成則等待完成延遲到 get 或 wait 才執行不呼叫就不會執行

圖4:行為不只取決於啟動策略,也取決於 future 要持有到什麼時候。

3.3. 平行迴圈要確認粒度與例外邊界

PPL(Parallel Patterns Library)的 concurrency::parallel_forparallel_for_each,是把處理平行套用到集合各元素上的工具。不過,單次迭代的工作太小時,fork/join 與排程的額外負擔就會超過收益。原則是盡量在外層迴圈上表達平行化7

C++17 的平行演算法可以使用 std::execution::par。MSVC 已經平行化了主要的演算法,但這並不表示所有演算法都一定會平行執行。8

另外,使用標準執行策略的演算法,若例外從元素處理中洩漏出來,就會導致 std::terminate。需要的 try/catch 不只放在呼叫端,還要放進元素處理的回呼函式裡。這與第 6 章討論的執行緒函式例外邊界是同一個思路。

3.4. 要擁有執行緒,就得保證例外路徑下也會 join

std::thread 在既沒 join 也沒 detach、仍為 joinable 的狀態下被銷毀時,會呼叫 std::terminate。這與執行緒函式的工作是否做完無關,必須在物件這一側把合流完成。9

下面是展示這個陷阱的例子。

void process()
{
    std::thread worker([]{ HeavyWork(); });
    DoSomething();      // ← 如果這裡拋出例外……
    worker.join();      // ← 就無法到達join,會在worker的解構函式中terminate
}

只在末尾寫 join(),守不住中途的例外路徑。要用 std::thread,就用 try/catch 或 RAII 保證合流。如果能用 C++20,就std::jthread 當成預設,在 joinable 的情況下,解構函式會先發出停止要求再 join。在 MSVC 中,Visual Studio 2019 16.9 以後即可使用 <stop_token>jthread210

執行緒物件的銷毀與合流銷毀 joinable 的 thread 會導致 terminate,而銷毀 jthread 會發出停止要求並 join。threadjthread銷毀執行緒物件是否 joinable沒有要合流的對象型別是什麼std::terminate發出停止要求並 join處理本身必須能夠結束

圖5:jthread 把合流自動化了,但不會強制終止工作。

這裡被自動化的,是擁有方的停止要求與合流。它不是用來接住從執行緒函式洩漏出來的例外,也不是用來強制終止停不下來的處理。這兩件事會在第 6 章分別設計。

原則上不使用 detach()。失去合流手段之後,靜態變數與堆積的銷毀會與執行緒的執行互相競爭,導致結束時當機。請把「能夠等到它結束」當成設計的基本要求。

4. 減少共享 ── 分割、傳值、不可變化、佇列

4.1. 不要持有共享的合計,而是每個執行緒各持一份小計

成為競爭原因的共享可變狀態,在加上鎖之前就能減少。若是平行彙總,與其讓所有執行緒都去更新共享的合計值,不如各自做一份本地小計,最後只合流一次

對共享資料的寫入從「每次迭代」降到「每個執行緒一次」,同步成本與可能發生競爭的位置都能縮小。合流時的更新,用 std::mutexstd::atomicfetch_add 都可以。

從各執行緒的小計在最後合流不是每次都更新共享合計,而是把各執行緒的小計在最後一次彙入合計。分割輸入執行緒 A 的小計執行緒 B 的小計最後同步合流共享的合計值

圖6:迭代過程中只更新執行緒專屬的資料,把對共享資料的寫入集中到合流時刻。

4.2. 以值傳遞。但要連複製之後所指向的對象一起確認

啟動時把需要的資料以複製或移動的方式傳入,讓它成為該工作專屬的資料,之後就能減少同步。lambda 不要依賴 [&],而要採用明確擷取,原則上以複製或移動為主。若要使用參照擷取,被參照的對象必須比執行緒活得更久。

不過,即使複製了指標,它所指向的物件也不會變成專屬的。含有原生指標或 shared_ptr 的結構,仍然殘留著透過別名(alias)而來的共享。能夠判斷「因為複製了值所以安全」,只有在連同被參照的對象在內都不持有共享可變狀態、構成深層值圖的情況下才成立。

即使複製指標,被指向的對象仍然是共享的複製含有指標的值之後,兩個變數雖然不同,被指向的物件卻仍是同一個。複製指標值原本的值當中的指標同一個被參照的物件複製後的指標若要改寫就需要同步

圖7:不只看複製出來的值,還要確認從它參照出去的物件。

4.3. 要以 const 共享,就要做出「沒有人會變更」的狀態

像設定值、主檔資料、計算輸入這類建構後就不再變更的東西,可以用唯讀的方式共享。例如 std::shared_ptr<const Config> 會禁止透過該控制代碼進行變更。

但是,只要別處仍殘留非 const 的參照,或者 mutable 成員被改寫,競爭就依然存在。請把建構完成後就放掉非 const 的參照,此後任何人都不再改寫這一段也納入設計。

需要變更時,方針是不改寫既有物件,而是建立新的物件來替換。不過,替換用的指標本身的同步,以及舊物件的生命週期管理,仍需另外處理。這在 5.4 節確認。

4.4. 佇列要備妥容量上限,以及可以停止的等待

執行緒之間的交接,與其互相直接觸碰共享變數,不如交給生產者與消費者的佇列。本文所處理的 C++17/20 標準函式庫沒有 channel,因此用 mutex 與條件變數組合出一個小型佇列就是基本形式。

下面的範例以 C++20 為前提。由於使用可接收 std::stop_tokenwait,條件變數選用 std::condition_variable_anyPushPop 用回傳值來表示「已觀測到停止要求而放棄處理」。

template <typename T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
    {
        if (capacity == 0)                          // 容量為0會讓所有Push永遠等待,是個陷阱
            throw std::invalid_argument("capacity must be positive");
    }

    // 若已滿,則等到出現空位(或收到停止要求)為止。false代表收到停止要求。
    bool Push(T item, std::stop_token st)
    {
        {
            std::unique_lock lock(mtx_);
            if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
                return false;                       // 因停止要求而被喚醒
            if (st.stop_requested())                // 若空位與停止同時發生,優先處理停止,
                return false;                       // 開始停止後不再接受新的投入
            queue_.push(std::move(item));
        }
        not_empty_.notify_one();   // 通知要在鎖之外進行
        return true;
    }

    // 等待停止要求(stop_token)或元素到達。停止時回傳nullopt。
    std::optional<T> Pop(std::stop_token st)
    {
        std::optional<T> item;
        {
            std::unique_lock lock(mtx_);
            if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
                return std::nullopt;                // 因停止要求而被喚醒
            if (st.stop_requested())                // 若元素與停止同時發生,優先處理停止,
                return std::nullopt;                // 開始停止後不再著手新的工作
            item = std::move(queue_.front());
            queue_.pop();
        }
        not_full_.notify_one();
        return item;
    }

private:
    const std::size_t capacity_;
    std::mutex mtx_;
    std::condition_variable_any not_empty_;   // 為了使用支援stop_token的wait,選擇any版本
    std::condition_variable_any not_full_;
    std::queue<T> queue_;
};

這段程式碼要看的重點有以下三個。

確認點 程式碼上的對應 防止的問題
佇列滿了要怎麼辦 設下容量上限,在 Push 等待空位。容量 0 直接拒絕 生產超過消費,記憶體持續增加
從等待返回之後要確認什麼 把檢查佇列狀態的述詞傳給 wait 沒有通知就返回,或醒來時條件已經改變
要停止時能否從等待返回 使用支援 stop_tokenwait,並在操作前也確認停止 卡在等待空佇列或滿佇列而無法結束

滿了就讓生產端等待,這件事本身就構成背壓(backpressure),把過載傳遞到上游。另外,條件變數存在沒有通知卻醒來的虛假喚醒,因此等待要帶述詞。帶述詞的 wait 會代為執行重新確認條件的迴圈。4

具容量上限的佇列與解除等待生產端在滿了時等待空位,消費端在空了時等待元素,停止要求也會送達兩邊的等待。生產端滿了就等待空位具容量上限的佇列空了就等待元素消費端停止要求

圖8:容量上限形成背壓,停止要求也會送達生產端與消費端的等待。

這個範例的方針是:觀測到停止之後,不是把剩下的全部處理完,而是不再進行下一次的投入與取得。不過,它並沒有把停止要求與已經在進行中的操作做成一個不可分割的處理。對於剛通過停止確認之後才收到要求的操作,並不保證會把它一併撤回,處理中的工作交由第 6 章的協調式停止處理。

範例只是同步與停止的骨架。需要的標頭與業務專屬的型別請在整合處自行準備,元素的移動或佇列操作失敗的情況,也交由第 6 章的例外邊界處理。

5. 保護剩下的共享 ── 鎖的紀律與 atomic 的適用範圍

5.1. 鎖要對應到所保護的資料,而不是程式碼

若無法把共享可變狀態歸零,就要為每一組要保護的資料對應一個 mutex,並在所有存取處都取用同一個 mutex。基本做法是不把 mutex 對外公開,而是與資料一起放成 private 成員。1

持有鎖的期間,只短暫地讀寫所保護的資料。在持有鎖時呼叫檔案 I/O、網路呼叫、回呼函式這類外部程式碼,不僅會拉長持有時間,還會製造出與被呼叫方的鎖互相等待的路徑。目標是在鎖外準備,在鎖內只做替換的形式。

在鎖外準備,在鎖內替換把耗時的準備移到鎖之外,只在短短的持有鎖區間裡變更共享資料。在鎖外準備以 RAII 取得鎖變更共享資料離開作用域後釋放執行對外的通知等動作

圖9:讓所保護的資料與 mutex 一一對應,不把外部處理帶進持有鎖的區間。

5.2. 取得與釋放交給 RAII,多個鎖要一併取得

手寫 lock()unlock() 讓兩者互相對應,會因為提前 return 或例外而忘記釋放。取得與釋放請交給下列包裝器。

包裝器 用途
std::lock_guard 只在作用域期間持有一個互斥鎖,最基本的形式
std::scoped_lock(C++17) 同時取得多個互斥鎖。以死結迴避演算法解決順序問題3
std::unique_lock 想在中途釋放、重新取得,或要傳給 condition_variable::wait 時使用

若要分別取得多個鎖,就要讓所有執行緒的取得順序一致。若是需要同時持有的鎖,一併傳給 std::scoped_lock,就能把取得時的死結迴避交給函式庫。這是處理所傳入那組 mutex 之取得的機制,並不能防止持有鎖時對外呼叫等其他形式的循環等待。3

下面是同時保護兩個帳戶的例子。請把注意力放在鎖的取得方式上,而不是業務上的餘額檢查等等。

void Transfer(Account& from, Account& to, int amount)
{
    if (&from == &to) return;                  // 若為同一帳戶則不做任何事(見下方注記)
    std::scoped_lock lock(from.mtx, to.mtx);   // 兩個鎖一併取得,順序交由函式庫解決
    from.balance -= amount;
    to.balance   += amount;
}

開頭的同一性檢查是必要的。把同一個帳戶傳給兩個引數,就等於把同一個不可重入的 mutex 傳了兩次,會成為卡死或未定義行為的原因。凡是「同時鎖住兩者」的函式,都要排除同一物件。

5.3. shared_mutex 與 recursive_mutex 要確認用途再選

對於讀取多、寫入少的資料,可以用 C++17 的 std::shared_mutex 來實作讀寫鎖。11

recursive_mutex 是允許同一執行緒重複取得的型別。不過,需要重複取得往往是鎖的職責變得模糊的訊號,因此在換掉型別草草了事之前,請先重新檢視結構。

5.4. atomic 的更新與物件的生命週期是兩回事

std::atomic 提供對單一變數的不可分割操作,以及基於 memory_order 的順序性。主要的用武之地是計數器與旗標的更新。若想讓多個變數作為一組維持一致,只把各個變數做成 atomic 是不夠的,要用 mutex 一併保護。5

尤其 std::atomic<T*>只是讓指標的替換變成不可分割,並不會延長被參照對象的生命週期。讀取方剛取得舊指標,寫入方就緊接著替換並 delete 掉舊物件,讀取方就會碰到已釋放的記憶體。

只靠 atomic 的指標交換守不住生命週期讀取方取得的舊指標,在寫入方交換之後刪除時就失去了參照對象。讀取方取得舊指標寫入方交換指標寫入方刪除舊對象讀取方存取舊對象存取已釋放的記憶體只有交換的不可分割性並不足夠

圖10:指標的交換與被參照對象的生命週期,必須分別加以保護。

若要以替換不可變物件的方式共享,請選擇附帶生命週期管理的手段,例如以鎖保護的 std::shared_ptr<const T> 置換,或 C++20 的 std::atomic<std::shared_ptr<T>>。即使選了 shared_ptr,也不代表可以隨意改寫被參照的資料,這一點與 4.3 節所述相同。

另外,volatile 在這裡同樣不是替代品。業務應用程式請使用 atomic 的預設值 seq_cst,或乾脆用 mutex 撰寫。放寬 memory_order 的無鎖設計,是只有在能說明其必要性與驗證手段時才成立的專業選項。

6. 完成停止機制 ── 要求、解除等待、例外處理與 join

6.1. request_stop 只是要求,join 完成才是停止的確認

在審查時,比起啟動方式,更要先能說明「這個要怎麼停下來」。在 C++ 中,並不存在能從外部安全強制終止執行緒的手段。Win32 的 TerminateThread 有多危險,在C 語言篇中討論。

基本形式是協調式停止。停止方只發出要求,執行緒自己在能夠收尾的地方結束,再由擁有者確認 join 完成。在 C++20 中,jthreadrequest_stop() 會把要求傳達給傳入執行緒函式的 stop_token2

在計算迴圈中確認 stop_requested(),若正在等待則用 condition_variable_any::wait(lock, st, pred) 接收停止要求。第 4 章的佇列,在等待空或等待滿時都能經由這條路徑返回。能夠藉由停止要求解除等待,與「包含排程器與重新取得鎖在內,停止會在一定時間內完成」並不是同一回事。

協調式停止把直到 join 為止串成一條路徑擁有者發出停止要求,計算與等待觀測到它而結束,並以 join 完成來確認停止。擁有者發出停止要求傳達給 stop_token計算中確認要求解除對應的等待收尾後 return擁有者的 join 完成

圖11:確認停止的依據不是發出要求的時點,而是 join 完成。

6.2. 以生命週期與重複啟動的角度來讀 Worker 的例子

下面是使用第 4 章 BlockingQueue 的 C++20 工作者執行緒。WorkItemProcessReportError 是業務端要自行準備的型別與函式。特別是 ReportError,要實作成不會拋出例外。

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // 拒絕在運作期間重複呼叫Start。
            throw std::logic_error("already running"); // 若不拒絕而直接賦值,新執行緒開始運作
                                                       // 後、在等待舊執行緒停止的期間,
                                                       // 會有兩個worker同時運作
        thread_ = std::jthread([this](std::stop_token st) {
            try {
                while (!st.stop_requested()) {
                    if (auto item = queue_.Pop(st)) {   // 即使收到停止要求也會醒來
                        try {
                            Process(*item, st);          // 內部可能阻塞的處理也要傳入st
                        } catch (...) {
                            ReportError(std::current_exception());  // 記錄單筆失敗並繼續
                        }
                    }
                }
            } catch (...) {
                // 執行緒邊界的最後防線(Pop或移動的失敗也在此接住)。
                // 若從這裡讓例外洩漏,會因std::terminate而連處理程序一起掛掉,
                // 因此ReportError要實作成不會拋出例外
                ReportError(std::current_exception());
            }
        });
    }
    // 不需要明確呼叫Stop:
    // Worker的解構函式 → jthread的解構函式 → request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // 附有容量上限(第4章)
    std::jthread thread_;
};

Start() 開頭,拒絕了對 joinable 執行緒的重複啟動。因為若不檢查就直接賦值新的 jthread,在新執行緒開始運作之後、等待舊執行緒停止的這段期間,兩條執行緒有可能同時運作。這項檢查並不是用來同步多個呼叫端同時呼叫 Start() 的鎖。前提是啟動與銷毀由擁有者這一側序列化管理

成員的宣告順序也是生命週期的一部分。範例中 thread_ 宣告在 queue_ 之後,因此銷毀時是 thread_ 先。等它的停止要求與 join 完成之後,才銷毀工作者執行緒所使用的佇列。

Worker 的成員銷毀順序與佇列的生命週期先銷毀較晚宣告的 jthread,合流完成後再銷毀工作者執行緒所使用的佇列。銷毀 Worker銷毀較晚宣告的 thread_停止要求與 join確認工作者執行緒已結束銷毀 queue_

圖12:不要在 join 之前就銷毀工作者執行緒所參照的佇列。

6.3. jthread 不會接住從工作者執行緒洩漏出來的例外

自動 join 與執行緒函式的例外處理是兩回事。只要例外從函式中洩漏出來,jthread 也和 std::thread 一樣會導致 std::terminate

範例中的 try/catch 有兩個角色。

例外邊界 對象 範例的方針
內層 Process 造成的單筆工作失敗 記錄後前往下一筆工作
外層 包含 Pop 與元素移動在內的整個迴圈失敗 作為最後防線記錄下來,不讓例外漏到函式之外
單筆工作與整個執行緒的例外邊界在不同的邊界接住單筆工作的失敗與佇列操作等失敗,不讓例外漏到執行緒函式之外。單筆的例外取得等操作的例外工作者執行緒的迴圈從佇列取得處理單筆工作在內層記錄後繼續在外層記錄後結束記錄處理本身也不拋出例外

圖13:不要依賴自動 join,要明確畫出執行緒函式的例外邊界。

在實際的業務中,要決定單筆失敗之後是繼續執行,還是透過錯誤通道回報給擁有者並停止。不要 catch 之後就默默丟掉,而要讓它可以被觀測。

6.4. 把可停止的路徑一路打通到 Process 內部

之所以連 Process(*item, st) 也要傳入 token,是因為不只在等待佇列的時間裡,處理單筆工作的過程中也必須對停止有所反應。長時間的計算或等待網路若沒有觀測到要求,解構函式中隱含的 join 就會一直等到那個處理結束為止。

協調式停止唯有在停止的路徑抵達所有等待之處時才能成立。對於無法中斷的外部呼叫,要加上逾時,為單筆的執行時間設下上限。只是把停止 token 加進引數,並不會讓那個外部 API 變成可以中斷。

6.5. 在 C++17 以前,要把停止旗標與通知成套處理

在無法使用 C++20 停止機制的環境中,用 std::atomic<bool> 的停止旗標加上條件變數的 notify_all 組出相同的架構。等待端的述詞也要把停止旗標納入。因為若只立起旗標而不通知,等待中的執行緒就不會醒來。4

此外,即使旗標是 atomic,仍需要一套紀律,避免在確認條件與開始等待之間漏接通知。停止狀態的變更也要用與等待端相同的 mutex 來協調,先改變狀態再發出通知。請把「防止值的資料競爭」與「不漏接喚醒通知」分開確認

7. 整合進 Windows ── 同步 API、DLL 與 UI 的界線

7.1. 一般的 C++ 用標準函式庫,Win32 整合則依需求選擇

在重視可移植性的 C++ 程式碼中,預設使用 std::mutexstd::shared_mutex 搭配 RAII。選擇 Win32 同步物件的理由,是要與 Win32 的等待 API 整合,或是需要跨處理程序的同步。12

狀況 選擇
一般的處理程序內互斥 std::mutex + RAII(預設)
讀取多、寫入少 std::shared_mutex
想以 WaitForMultipleObjects 同時等待多個物件 Win32 的事件、Mutex 等核心物件
跨處理程序的互斥與通知 具名 Mutex、事件、號誌
直接使用 Win32 API 的處理程序內鎖 SRW 鎖(只有需要重複取得時才用CRITICAL_SECTION)12
標準同步與 Win32 整合的選擇一般的 C++ 程式碼以標準同步為預設,若需要 Win32 等待或跨處理程序同步則選擇 Win32 物件。確認同步的需求是否需要 Win32 等待或跨處理程序以標準 mutex 與 RAII 為預設選擇 Win32 物件若直接用 Win32 則選 SRW 等

圖14:依與作業系統整合的需求來選擇,不要和一般的處理程序內互斥混為一談。

不要把處理程序內一般的互斥換成 Win32 Mutex。因為它伴隨核心轉換,用在這個用途上只是多餘的成本。界線是:直接使用 Win32 的新程式碼用 SRW 鎖,只有需要同一執行緒重複取得時才用 CRITICAL_SECTION。12

跨處理程序保護共享記憶體的具體設計,請參考「使用共享記憶體時的陷阱與實務最佳實踐」。

7.2. 不要在 DllMain 中啟動執行緒、同步或等待結束

DllMain 是在持有載入器鎖的狀態下被呼叫的。在其中與其他執行緒同步、等待結束,或呼叫 LoadLibrary,都會成為死結等問題的原因。13

像啟動執行緒、與執行緒合流這類初始化或結束處理,請移到 DllMain 之外的明確函式中。並不會因為用的是 jthread 就成為例外,要連自動 join 發生在哪裡都一併確認。

把 DLL 的執行緒處理分離到明確的函式在持有載入器鎖的 DllMain 中不做同步,把執行緒的啟動與結束等待分離到外層的函式。DllMain持有載入器鎖中不放置啟動、同步與 joinDllMain 之外的明確函式管理啟動與停止、合流

圖15:因銷毀執行緒物件而產生的隱含 join,也要確認它執行的位置。

7.3. UI 更新要委託給建立它的執行緒,不要用同步通知互相等待

視窗與控制項的操作,要集中到建立它們的 UI 執行緒上。不要從工作者執行緒直接更新畫面,基本做法是以非同步的 PostMessage 委託,由 UI 端的視窗程序來處理

同步形式的 SendMessage,若在 UI 執行緒正等待工作者執行緒完成時呼叫,就會製造出循環等待。把來自工作者執行緒的通知做成非同步形式,正是為了避開這條路徑。

UI 與工作者執行緒因同步通知造成的循環等待UI 等待工作者執行緒完成,工作者執行緒又等待 SendMessage 處理完畢,就形成循環等待。等待工作者執行緒完成等待 SendMessage 完成UI 執行緒工作者執行緒來自工作者執行緒的通知以 PostMessage 委託在 UI 端處理

圖16:對 UI 的委託以非同步形式為預設,不製造互相等待完成的狀況。

牽涉到 COM 時的 STA/MTA 限制,請在「COM STA/MTA 基礎知識」中確認。C++/CLI 也另有限制,以 /clr 編譯的程式碼中,<thread><mutex> 等標準執行緒標頭會被封鎖。14

8. 進行驗證 ── 以設計表、觀測與壓力測試三層來準備

8.1. 比起執行結果,先確認能否說明共享與停止

即使一般的測試通過了,也可能只是那一次執行剛好沒有發生競爭。第一道防線是到目前為止的設計。審查時請把下列項目做成表格來確認。

對象 必須能夠說明的事
正在共享的可變資料 由誰讀寫,由哪個 mutex 保護
多個鎖 取得順序是否已經統一,是否用 scoped_lock 一併取得
傳遞出去的資料 複製之後是否仍殘留別名,生命週期是否足夠
停止 在哪裡觀測要求、解除等待,以及由誰 join

說不出這套對應關係的設計,即使能動也還沒完成。

8.2. 用逾時、日誌與傾印檔讓異常變得可見

對於不應該取不到的鎖或結束不了的等待,可以考慮 timed_mutex::try_lock_forcondition_variable::wait_for 這類逾時。把逾時記錄到日誌,就能把無聲的卡死轉成可偵測的失敗。在執行緒邊界捕捉到的例外,也務必記錄下來。

現場一旦發生卡死或當機,就從傾印檔確認所有執行緒的堆疊,追查鎖的等待是否形成循環。準備方式在「Windows 應用程式當機時保留日誌與傾印檔的設計」中討論。

8.3. 在發行組建下也要搖動執行順序與負載

以超過核心數的平行度長時間運行、把處理順序隨機化、插入人工延遲,這類壓力測試能讓有問題的執行順序更容易被抽中。不只是偵錯組建,也要對最佳化過的發行組建施加負載。

備妥設計與觀測之後再做壓力測試先用設計確認共享與停止,讓日誌與傾印檔可供觀測,再改變負載與執行順序來測試。確認共享、鎖、生命週期與停止備妥日誌與傾印檔改變負載與執行順序來測試把發現的問題回饋到設計

圖17:不要只把測試成功當成安全性的證明,而要把設計、觀測與測試組合起來。

測試不是設計的替代品。順序是先用結構守住共享與生命週期,再觀測異常,並用測試找出脆弱的地方

9. 總結 ── C++ 版檢查清單

最後,用 C++ 的實作來確認:不直接增加執行緒、減少共享可變狀態、讓鎖與資料一一對應,以及採用協調式停止。

  1. 是否有裸用 std::thread(能否改成 jthread,即使走例外路徑 join 也有保證嗎)
  2. 是否有使用 detach()
  3. lambda 擷取是否明確,以參照擷取的變數,其生命週期是否長於執行緒
  4. 是否能斷言沒有任何一處未同步的共享可變存取(=未定義行為)
  5. 是否沒有手寫 lock() / unlock(),多個鎖是否以 scoped_lock 一併取得
  6. condition_variable::wait 是否全部都帶有述詞
  7. 共享旗標是否沒有使用 volatile(是否已改為 std::atomic
  8. 停止路徑是否以 stop_token(或 atomic 旗標 + 通知)設計,並以 join 確認合流完成
  9. std::async 的 future 是否沒有被捨棄
  10. 是否沒有在 DllMain 中進行執行緒的啟動、同步與 join

在 C++ 中資料競爭會變成未定義行為,因此不能依賴「平常都能動」。即使如此,只要順著 RAII 與標準函式庫的作法走,就能從結構上減少收尾與同步的失誤。

選擇執行方式、減少共享、保護剩下的共享,並把從停止要求到 join 的流程做完整。 在這個順序中使用 jthreadscoped_lock、帶述詞的 waitatomic,就是實務的基本。

相關文章

相關諮詢領域

合同會社小村軟體承接 C++ 製應用程式・DLL 的多執行緒設計審查、「偶爾當機・只有在發行組建下才不對勁」這類由競爭引起的缺陷調查(傾印檔分析),以及舊有執行緒程式碼遷移至現代 C++ 的諮詢。

參考連結

  1. ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism。關於並行・平行章節開頭列出的規則 CP.1(假設自己的程式碼會在多執行緒下執行)與 CP.2(避免資料競爭),資料競爭一旦發生就沒有任何保證能夠成立,以及鎖的持有範圍、RAII 的運用等並行程式碼的設計規則已被體系化的說明。  2 3 4 5 6

  2. cppreference.com, std::jthread。關於 C++20 的 jthread 與 std::thread 不同,會在解構函式中自動呼叫 request_stop() 後再 join,執行緒函式可以將 std::stop_token 作為第一個參數接收,並藉此即使發生例外,執行緒的合流與停止要求也能獲得保證的說明。  2 3

  3. Microsoft Learn, scoped_lock Class。關於 C++17 的 scoped_lock 會在建構時取得一個以上的互斥鎖,並在解構函式中釋放,若傳入多個互斥鎖,則會以相當於 std::lock 的死結迴避演算法取得,即使拋出例外也能確實釋放,若只有單一互斥鎖則 lock_guard/unique_lock 也是選項的說明。  2 3

  4. Microsoft Learn, <condition_variable>。關於條件變數的等待需要互斥鎖,等待期間鎖會被釋放,由於存在沒有通知卻醒來的虛假喚醒,等待方應在恢復時明確確認條件,帶述詞的 wait(lock, pred) 會代為執行該迴圈,以及 condition_variable_any 可與任意互斥鎖型別搭配使用的說明。  2 3

  5. Microsoft Learn, <atomic>。關於原子操作因不可分割,其他執行緒只能觀測到操作前或操作後的狀態,依 memory_order 引數建立對其他原子操作可見性的順序性要求,並抑制違反該要求的編譯器最佳化,atomic_flag 永遠是無鎖的,以及在 /clr:pure 下這個標頭會被封鎖的說明。  2 3

  6. Microsoft Learn, <future>。關於 future 與 shared_future 的解構函式原則上不會阻塞,但唯一的例外是與 std::async 啟動的工作綁定的 future(或最後一個 shared_future),若在工作尚未完成時解構函式就執行,會阻塞到共享狀態變為 ready 為止,而這項行為已明確記載於規格的注記中的說明。 

  7. Microsoft Learn, Best Practices in the Parallel Patterns Library。關於平行化應盡可能在較高的層級(外層迴圈)表達,在每次迭代工作量小・不均衡的平行迴圈中,fork/join 的排程額外負擔可能超過平行執行帶來的收益,而這個傾向會隨處理器數量增加而加劇的說明。 

  8. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version。關於 C++17 的平行演算法函式庫雖已完成,但「完成」並不代表所有演算法在所有情況下都會被平行化,而是最重要的演算法已平行化,未平行化的部分也提供執行策略簽名的實作方針的說明。 

  9. cppreference.com, std::thread::~thread。關於 std::thread 的解構函式,在執行緒仍為 joinable(既未 join 也未 detach)的狀態下被呼叫時會呼叫 std::terminate,也就是在執行緒物件銷毀之前,必須先完成 join 或 detach 的判斷的說明。 

  10. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version。關於 P0660R10(<stop_token> 與 jthread)以及 P1135R6(C++20 同步函式庫)在 Visual Studio 2019 16.9 中獲得支援,以及 C++ 標準函式庫功能各版本支援狀況的說明。 

  11. Microsoft Learn, C++ standard library header files。關於整理了多執行緒相關的標準標頭,包括 <atomic>(C++11)、<mutex>(C++11)、<shared_mutex>(C++14)、<condition_variable>(C++11)、<future>(C++11)、<stop_token>・<semaphore>・<latch>・<barrier>(C++20)、<thread>(C++11)的說明。 

  12. Microsoft Learn, About Synchronization。關於 Win32 同步基元的選擇指引,重視可移植性的 C++ 程式碼建議使用 std::mutex / std::shared_mutex 與 RAII,需要 Win32 等待 API 或跨處理程序同步時使用 Win32 同步物件,處理程序內新程式碼的預設是 SRW 鎖,只有需要再入取得時才用 CRITICAL_SECTION,處理程序內同步使用 Mutex 是總是伴隨核心轉換的「常見錯誤」的說明。  2 3

  13. Microsoft Learn, Dynamic-Link Library Best Practices。關於 DllMain 是在持有載入器鎖的狀態下被呼叫,因此可呼叫的 API 有重大限制,在 DllMain 內與其他執行緒同步可能導致死結,呼叫 LoadLibrary 或等待執行緒結束是典型的禁止事項,初始化應盡量延後並移到 DllMain 之外,以及應定義鎖階層並將載入器鎖置於最上層的說明。 

  14. Microsoft Learn, <thread>。關於 <thread> 標頭定義了 thread 類別與 sleep_for 等輔助函式,以 /clr 編譯的程式碼中這個標頭會被封鎖,以及可透過 STDCPP_THREADS 巨集判斷是否支援執行緒的說明。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

std::mutex 與 Win32 的 CRITICAL_SECTION・SRW 鎖,應該如何區分使用?
在重視可移植性的一般 C++ 程式碼中,std::mutex / std::shared_mutex 搭配 RAII 包裝器(lock_guard / scoped_lock)是第一候選。選擇 Win32 的同步物件,是在想與 WaitForMultipleObjects 這類 Win32 等待 API 搭配使用,或需要用具名物件跨處理程序同步的情況下。若在處理程序內直接使用 Win32 API,新程式碼的預設選擇是 SRW 鎖,只有在需要同一執行緒可重入取得時才使用 CRITICAL_SECTION。在處理程序內以 Win32 Mutex 做互斥,是一種典型的錯誤——它總是伴隨核心轉換而導致速度變慢。
可以使用 std::thread 的 detach() 嗎?
原則上應該避免。detach 之後的執行緒會失去合流(join)的手段,在處理程序結束時,也無法控制該執行緒是否仍在執行。靜態變數或堆積被解構之後,已 detach 的執行緒仍繼續執行、進而在結束時造成當機,是典型的事故。「能夠等待結束」是執行緒設計的基本要求,因此請使用 jthread(自動 join),或者若使用 thread,也務必建構成在離開作用域之前一定會 join 的結構。只有在可以與處理程序共存亡、並且能保證完全不觸碰共享狀態的有限場景中,才允許使用 detach。
在 C++ 中,volatile 可以用於執行緒間的同步嗎?
不可以。C++ 的 volatile 是為了記憶體映射 I/O 這類「不希望編譯器最佳化的讀寫」而設的修飾詞,並不保證執行緒間的可見性或順序性。多個執行緒在沒有同步的情況下存取同一個變數,就是資料競爭,屬於未定義行為。在執行緒間共享的旗標或計數器應使用 std::atomic,若要一併保護多個變數則使用 std::mutex。std::atomic 同時提供了操作的不可分割性,以及基於 memory_order 的順序性保證。
std::async 看起來很方便,有什麼陷阱嗎?
最大的陷阱在於 future 的解構函式。與 std::async 啟動的工作綁定的 future(或最後一個 shared_future),若在工作尚未完成時解構函式就執行,會一直阻塞到完成為止。若不接收回傳的 future 就將其捨棄,該處就等同於同步執行,會發生「原本想做成非同步,結果卻變成序列執行」的事故。此外,若未指定啟動策略,是否真的會在另一個執行緒上執行,取決於實作的裁量。若要使用,請明確管理 future 的生命週期,並在確實需要並行執行的地方指定 std::launch::async。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽