「把在 C# 中運作正常的設計搬到 C++,結果偶爾就會當機」「用了 std::thread,結果例外發生時整個應用程式跟著 terminate 當場暴斃」「原本用 volatile bool 的旗標來停止,但只有在發行組建下停不下來」── C++ 的多執行緒有著受管理語言所沒有的獨特恐怖之處。這是因為資料競爭會直接變成未定義行為(undefined behavior)。問題不只是「讀到損壞的值」而已,編譯器最佳化的前提會因此崩潰,進而陷入「發生什麼事都不奇怪」的狀態。
本文是多執行緒實務系列的C++ 篇。以用現代 C++(C++17/20)撰寫業務應用程式、裝置控制、DLL 的開發者為對象,將多執行緒設計的原則 ── 不直接增加執行緒、減少共享可變狀態、鎖的紀律、事先設計好停止方式 ── 落實到 C++ 與 Windows 的工具中,並結合 C++ 特有的陷阱,依據 2026 年 8 月當下的一手資訊加以整理。本文即使單獨閱讀也能理解。將相同原則以其他語言展開的文章還有「多執行緒實務最佳實踐 .NET 篇」「多執行緒實務最佳實踐 C 語言篇」「多執行緒實務最佳實踐 Java 篇」。
1. 先講結論
- 在 C++ 中,資料競爭不是「讀到損壞的值」,而是「未定義行為」。不留下任何一處未同步的共享可變存取,是比其他語言更絕對的必要條件。1
- 請不要直接裸用
std::thread。若std::thread在仍是 joinable 的狀態下解構函式被執行,會透過std::terminate讓處理程序當場暴斃。C++20 的std::jthread會在解構函式中自動 join,也內建了停止要求(stop_token)機制。23 - 鎖一定要透過 RAII 持有。不要手寫
mtx.lock(),改用lock_guard/scoped_lock。即使拋出例外,解構函式也會確實釋放。同時取得多個鎖時,scoped_lock會用死鎖迴避演算法來處理。4 volatile不是同步的工具。共享旗標・計數器要用std::atomic,保護多個變數則用std::mutex。std::atomic提供不可分割性以及基於memory_order的順序性。5- 等待要用
condition_variable的帶述詞wait。條件變數存在虛假喚醒(spurious wakeup,未收到通知卻醒來的現象),因此不帶述詞的wait是錯誤的溫床。6 - 停止方式的基本形是
jthread+stop_token(C++20)。在此之前的環境中,則以std::atomic<bool>+ 條件變數組出協同停止機制。請把強制終止執行緒視為 C++ 的世界中不存在的東西。3 - 使用
std::async之前,請先了解 future 的解構函式會發生阻塞。捨棄回傳值就等同於序列執行。7 - Win32 同步物件的用武之地只有「與 Win32 等待 API 搭配」和「跨處理序」這兩種情況。除此之外用標準函式庫撰寫,在可移植性、可維護性方面更有利。8
2. 為什麼多執行緒很困難 ── 競爭狀態・死鎖・未定義行為
多執行緒帶來的問題,不論語言為何,追根究柢可歸結為兩種。
競爭狀態(race condition)是指結果會因多個執行緒到達特定程式碼的順序而改變的臭蟲。經典的例子是共享計數器:++count 這一個表達式,在機器語言層級會分成「讀取 → 加算 → 寫回」三個步驟。當兩個執行緒同時進入這三個步驟時,其中一方的加算會被另一方的寫回覆蓋而消失。每次執行結果都會改變,也無法預測會得到哪個結果。
sequenceDiagram
participant A as 執行緒A
participant M as 共享變數 count
participant B as 執行緒B
Note over M: count = 10
A->>M: 讀取(10)
B->>M: 讀取(10)
A->>A: 本地加算(11)
B->>B: 本地加算(11)
A->>M: 寫回(11)
B->>M: 寫回(11)
Note over M: 加算了兩次卻是 count = 11<br/>執行緒A的加算遺失了
圖1:共享計數器發生加算遺失的典型競爭狀態。當另一個執行緒在 ++count 的三個步驟之間插入時,較晚寫回的一方會覆蓋前者
死鎖是指兩個執行緒互相等待對方持有的鎖,結果誰都無法繼續前進的狀態。執行緒A持有鎖1並等待鎖2,執行緒B持有鎖2並等待鎖1 ── 光是這樣,兩者就會永遠停住。
flowchart LR
A["執行緒A<br/>持有鎖1"] -->|"等待鎖2釋放"| B["執行緒B<br/>持有鎖2"]
B -->|"等待鎖1釋放"| A
圖2:死鎖的循環等待。當等待箭頭形成一個環的瞬間,環中的所有執行緒都會永遠停止
麻煩的是,這兩者都與時序有關。在開發機上數萬次才會中一次的執行順序組合,在核心數與時序都不同的客戶端機器上卻可能每天發生。「接上偵錯器就不會重現」「加了記錄就消失了」,也是因為觀測本身改變了時序,這是競爭臭蟲的典型行為。正因如此,本文的所有原則都指向同一個方向:在「正確地同步」之前,先「減少需要同步的地方」。
2.1. 在 C++ 中,資料競爭會直接變成未定義行為
在此之上,C++ 還有比其他語言更深一層的事情。依 C++ 規格,若多個執行緒在沒有同步的情況下存取同一個記憶體位置,且至少有一方是寫入,那就是資料競爭,屬於未定義行為。C++ Core Guidelines 的並行章節(CP.2「避免資料競爭」)將此列為最開頭的絕對規則。1 未定義行為並不是「讀到舊值或新值其中之一」這麼溫和的事。編譯器會在「不存在資料競爭」的前提下進行最佳化,因此迴圈中的條件判斷消失、寫入被重新排序或合併等等,從原始碼難以想像的行為都可能正當地發生。「volatile bool 的停止旗標只在發行組建中失效」這種經典事故,正是其典型表現。
2.2. RAII 是根基
另一個 C++ 特有的前提是例外與資源管理。C++ 沒有 finally,取而代之的是 RAII(透過解構函式自動釋放),多執行緒的工具組也是以 RAII 為前提設計的。「用物件的生命週期管理鎖」「也用物件的生命週期保證執行緒的合流」── 順著這套作法走,就是在 C++ 中安全撰寫多執行緒的根基。
3. 建立執行緒的方式 ── thread 的陷阱與 jthread
3.1. std::thread 的解構函式是「會出事的規格」
std::thread 有一個有名的陷阱。若在仍是 joinable 的狀態下(既沒有 join 也沒有 detach)解構函式被執行,就會呼叫 std::terminate,讓處理程序當場暴斃。9
void process()
{
std::thread worker([]{ HeavyWork(); });
DoSomething(); // ← 如果這裡拋出例外……
worker.join(); // ← 就無法到達join,會在worker的解構函式中terminate
}
要做到例外安全,就必須用 try/catch 保證 join 一定會執行,這是一種明明是 RAII 語言、卻只有執行緒要手動管理的扭曲狀態。C++20 的 std::jthread 解決了這個問題。它會在解構函式中自動發出停止要求並 join,因此只要把上面的程式碼換成 std::jthread,就能變成例外安全。2 在 MSVC 中,Visual Studio 2019 16.9 以後即可使用 <stop_token> 與 jthread。3
flowchart TB
T["啟動了一個執行緒"] --> Q{"離開作用域時<br/>會發生什麼?"}
Q -->|"std::thread<br/>沒有join也沒有detach"| X["std::terminate<br/>處理程序當場暴斃"]
Q -->|"std::thread<br/>已join"| OK1["安全合流"]
Q -->|"std::jthread (C++20)"| OK2["自動執行request_stop + join<br/>即使拋出例外也安全"]
圖3:執行緒物件的生命週期與結束方式。std::thread 是「忘記join就會暴斃」的規格,因此 C++20 以後應以 jthread 為預設
原則上不使用 detach()。失去合流手段的執行緒,會在處理程序結束時與靜態變數或堆積的解構發生競爭,成為結束時當機的典型原因。
3.2. 「高於執行緒層級」的工具 ── async・future 與平行演算法
.NET 篇中「不要自行建立執行緒」的原則,在 C++ 中對應到以下工具。
std::async+std::future:用於單次非同步工作與接收結果。但有一個重要規格:與std::async啟動的工作綁定的future(或最後一個shared_future),若在工作尚未完成時解構函式就執行,會阻塞到完成為止。7 若是以std::launch::async實際啟動的工作,一旦捨棄回傳的 future,該處就等同於同步執行。更糟的是,在不指定啟動策略的預設情況下,實作可能會選擇deferred(延遲執行),此時若沒有任何人呼叫get()/wait(),工作根本不會被執行,就這樣悄悄消失。若想確實並行執行,請明確指定std::launch::async,並由持有者管理 future 的生命週期。- PPL(Parallel Patterns Library)的
concurrency::parallel_for/parallel_for_each:對集合中所有元素進行平行處理。但若每次迭代的工作量太小,fork/join 的額外負擔就會吃掉平行化帶來的收益,因此原則上應在外層迴圈進行平行化。10 - C++17 的平行演算法(
std::execution::par):在 MSVC 中,主要的演算法已經平行化(但並非全部)。11 需要注意的是,若在執行策略下的元素處理中有例外洩漏出來,會呼叫std::terminate。在回呼函式中放置自己的例外邊界(try/catch),與第6章的執行緒邊界是相同的思路。
「等待 I/O 不是增加執行緒的對象」這條界線同樣適用。若是 Windows 原生程式碼,OVERLAPPED I/O 或 IOCP 就能承接這類需求(機制請參考「Windows I/O 的深層(第2回)」)。
4. 減少共享可變狀態 ── 分割・傳值・const・佇列
競爭只有在「多個執行緒」與「共享的可變資料」同時具備時才會發生。執行緒的數量由需求決定,因此設計上能削減的是共享的部分。手段可分為「分割」「不變化」「傳遞」三大類,在 C++ 中可以這樣寫。
分割。在平行加總這類處理中,不要讓各執行緒都寫入共享的合計變數,而是讓每個執行緒各自持有本地小計,最後只合併一次。共享寫入從「每次迭代」減少到「每個執行緒一次」,同步成本與競爭窗口都會大幅縮小。最後合併的那一次,用 std::mutex 或對 std::atomic 執行 fetch_add 都可以。
傳值。在啟動執行緒時,若把所需資料以複製(或移動)的方式傳遞,該資料就會變成該執行緒專屬,不需要同步。將 lambda 的擷取設為參照([&])、進而觸碰到生命週期已結束的變數,是常見的事故,因此傳給執行緒的 lambda 應採用明確擷取,原則上以複製或移動為主。不過,「因為複製了所以專屬」這個前提,只有在該值是不含指標或 shared_ptr 等別名(alias)的深層值圖時才成立。即使複製了含有原生指標的結構體,其所指向的目標仍然是共享的。
以 const 共享。只會被讀取的資料,不論多少個執行緒同時讀取都是安全的。設定值、主資料、計算輸入等,只要做成建構後不再改寫的 const 共享(例如 std::shared_ptr<const Config>),就能不經同步而共享。需要注意的是,shared_ptr<const T> 所禁止的只是透過該控制代碼進行的變更。若某處仍殘留非 const 的別名,或 mutable 成員被改寫,競爭仍會存在,因此請把「建構完成後放棄非 const 參照,此後任何人都不再寫入」也一併納入設計。只要決定「需要變更時,不改寫既有物件,而是建立新物件來替換」,就能少一個需要守護的可變狀態(替換本身的生命週期管理,請參考 5.2 的注意事項)。
以佇列傳遞。執行緒間的資料流動,應該交給生產者/消費者佇列,而不是共享變數。由於 C++ 標準沒有 channel,用 std::mutex + std::condition_variable 撰寫一個小型佇列是常見的定石。
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 發生阻塞,會自然地發揮背壓(backpressure)的作用,機械式地把過載傳遞到上游。第二,條件變數存在虛假喚醒(沒有通知卻醒來的現象),因此呼叫 wait 時務必附帶述詞。帶述詞的 wait 會在內部執行「迴圈直到條件為真」。6
5. 鎖的紀律 ── RAII 與 scoped_lock
即使減少了共享可變狀態,多數情況下也無法歸零。剩下的共享部分要用排他控制,但沒有紀律的鎖只是把競爭藏起來而已。
首先,鎖的單位不應該以「程式碼區段」來思考,而應該以「資料」來思考。為每一組想要保護的可變資料對應一個互斥鎖(做成不對外公開的 private 成員),並在所有觸碰該資料的地方都取用同一個互斥鎖 ── 競爭臭蟲的實際情況,正是這張對應表崩壞了。而且,持有鎖期間只能做守護對象資料的讀寫。持有鎖期間進行檔案 I/O、網路呼叫、回呼(呼叫外部程式碼),不僅會拉長持有時間,還會製造出被呼叫方試圖取得另一個鎖而導致死鎖的路徑。在鎖外準備,在鎖內只做替換才是基本形式。
5.1. 禁止手寫 lock()/unlock()
直接呼叫 std::mutex 的 lock() / unlock() 的程式碼,會因為例外或提前 return 而造成釋放遺漏。鎖的取得與釋放務必交給 RAII 包裝器處理。
| 包裝器 | 用途 |
|---|---|
std::lock_guard |
只在作用域期間持有一個互斥鎖,最基本的形式 |
std::scoped_lock(C++17) |
同時取得多個互斥鎖。以死鎖迴避演算法解決順序問題4 |
std::unique_lock |
想在中途釋放・重新取得,或要傳給 condition_variable::wait 時使用 |
當有兩個以上的鎖時,取得順序因執行緒而異,是死鎖的經典模式(圖2的循環等待正是這樣產生的)。對策是把「所有執行緒都以相同順序取得」制定成規則,但如果是同時取得的場景,C++ 有更好的答案:只要把多個互斥鎖一併傳給 std::scoped_lock,函式庫就會保證取得順序上的死鎖迴避。4 像是兩個物件之間的轉帳處理這種「想同時鎖住兩者」的場景,不要個別取得,而要一併取得。
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;
}
開頭的同一性檢查並非裝飾。若同一個 Account 同時被傳入 from 和 to,就會把同一個不可重入的互斥鎖傳給 scoped_lock 兩次,成為卡死或未定義行為的原因。凡是「同時鎖住兩者」的函式,都務必附上排除同一物件的檢查。
對於「讀取多但寫入少」的資料,可以用 std::shared_mutex(C++17)來實作讀寫鎖。12 另外,recursive_mutex 是「即使同一執行緒重複取得也不會壞」的型別,但需要再入取得的設計,往往是鎖的責任邊界不清晰的訊號,請優先考慮重新檢視結構。
5.2. atomic 的正確定位
std::atomic 提供了對單一變數的不可分割操作,以及基於 memory_order 的順序性。5 它的用武之地和 .NET 篇的 Interlocked 相同,都是像計數器、旗標這類單一變數的更新。它無法一併保持多個變數的一致性,此時應回到 std::mutex。
替換原生指標(std::atomic<T*>)有其固有的陷阱。即使替換本身是不可分割的,替換之後舊物件的生命週期卻沒有人守護。若讀者剛載入舊指標,寫入者就緊接著替換並 delete,就會存取到已釋放的記憶體。若要在 C++ 中實作「替換不變物件並共享」的設計,請選擇像是以鎖保護的 std::shared_ptr<const T> 置換(或 C++20 的 std::atomic<std::shared_ptr<T>>)這種與生命週期管理成套的手段。
再次重申,volatile 不是執行緒同步的工具。自行指定 memory_order 的無鎖程式設計,屬於擁有充分理由與驗證手段、可以從預設值(seq_cst)放寬要求的專家領域。在業務應用程式中,請維持預設值使用,或乾脆用 mutex 撰寫。
6. 停止方式的設計 ── stop_token 與協同停止
多執行緒設計審查時,第一個該問的問題是「這個要怎麼停下來」。而 C++ 中並不存在能從外部安全停止執行緒的手段(Win32 的 TerminateThread 有多危險,會在多執行緒實務最佳實踐 C 語言篇中詳述)。因此,停止方式要用 C++ 的工具組出協同停止 ── 停止方只發出要求,何時、如何結束由執行緒自己在收尾良好的地方決定,並以 join 完成來視為「已停止」。
C++20 中,std::jthread 內建了停止機制。呼叫 request_stop() 後,執行緒函式收到的 std::stop_token 就會被設定停止要求,迴圈會輪詢它。condition_variable_any 的 wait 可以直接接收 stop_token,因此「正在等待工作到來的執行緒」也能透過停止要求立即被喚醒(第4章的 BlockingQueue::Pop 就是這種形式)。
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_;
};
flowchart TB
OWNER["停止方<br/>jthread的解構函式或request_stop"] -->|"停止要求"| ST["stop_token"]
ST --> P["計算迴圈,<br/>輪詢stop_requested"]
ST --> W["等待中的執行緒,<br/>condition_variable_any.wait傳入lock、st、pred<br/>會立即醒來"]
P --> E["收尾後自行return"]
W --> E
E --> J["透過join完成合流<br/>此時才能說「已經停止」"]
圖4:C++20 的協同停止。停止方只發出要求,結束方式由執行緒自己決定,並以 join 完成來視為停止
還有一點,worker 內的 try/catch 是不能省略的。jthread 讓例外安全的只有 join 這件事,若例外從執行緒函式中洩漏出來,就會和 std::thread 一樣,因 std::terminate 而讓處理程序當機。單筆工作失敗要如何處理(記錄後繼續執行,還是透過錯誤通道回報給持有者),應在執行緒邊界上明確決定。
基於同樣的理由,請留意也把 stop_token 傳給了 Process。若單筆工作的處理內部會發生阻塞(例如等待網路、長時間計算等),一旦該處無法觀測到停止要求,解構函式暗中執行的 join 就會一直等待那一筆工作完成。協同停止唯有在「所有等待之處」都收到 token 之後才能成立。若其中包含無法中斷的外部呼叫,請加上逾時,為單筆執行時間設定上限。
在 C++17 以前的環境中,可以用 std::atomic<bool> 的停止旗標加上 condition_variable 的 notify_all,手動組出相同的架構。此時要點在於,把停止旗標的檢查納入條件變數的述詞中(若只設旗標而忘記通知,等待中的執行緒就會永遠不會醒來)。
7. Windows 特有的注意事項 ── 與 Win32 API 的界線
7.1. 標準函式庫與 Win32 同步物件的使用區分
Microsoft 的文件建議,重視可移植性的 C++ 程式碼應使用 std::mutex / std::shared_mutex,並將 Win32 同步物件的用武之地整理為「需要 Win32 等待 API 的情況」與「跨處理序同步」兩種。8
| 狀況 | 選擇 |
|---|---|
| 一般的處理序內排他 | std::mutex + RAII(預設) |
| 讀取多・寫入少 | std::shared_mutex |
想以 WaitForMultipleObjects 同時等待多個物件 |
Win32 的事件・Mutex 等核心物件 |
| 跨處理序的排他・通知 | 具名 Mutex・事件・號誌 |
| 直接使用 Win32 API 的處理序內鎖 | SRW 鎖(只有需要再入時才用CRITICAL_SECTION)8 |
跨處理序排他共享記憶體的具體設計,請參考「使用共享記憶體時的陷阱與最佳實踐」。
7.2. 不要在 DllMain 中觸碰執行緒
撰寫 DLL 時的一項重大限制是載入器鎖(loader lock)。DllMain 是在持有載入器鎖的狀態下被呼叫的,因此在其中與其他執行緒同步、等待執行緒結束、呼叫 LoadLibrary,這類操作都會成為死鎖或不確定行為的原因。像啟動、合流執行緒這類初始化,請移到 DllMain 之外(明確的初始化函式)進行。13
7.3. UI 執行緒與 COM Apartment
Windows 的桌面應用程式有一項與語言無關、始終有效的強制限制。視窗與控制項只能由建立它們的那個執行緒(UI 執行緒)觸碰,這是鐵律。Windows 會把視窗訊息投遞到建立該視窗的執行緒的訊息佇列,因此 UI 的建立與操作必須集中在該執行緒上。想從工作執行緒更新畫面時,不要直接觸碰,而應以 PostMessage(非同步)委託給 UI 執行緒,由 UI 執行緒端的視窗程序來處理。同步形式的 SendMessage,若在 UI 執行緒正在等待該 worker 完成的情況下呼叫,就會變成互相等待的死鎖,因此來自 worker 的通知應以非同步形式為預設。牽涉到 COM 時的 STA/MTA,在「COM STA/MTA 基礎知識」中有解說。另外也需要留意,以 /clr 編譯的 C++/CLI 程式碼中,<thread>、<mutex> 等標準執行緒標頭會被封鎖。14
8. 驗證與除錯 ── 以「無法重現」為前提做好準備
競爭臭蟲不能期待靠測試找出來。因為一般的測試會把「碰巧沒有發生競爭」的執行算作成功。準備工作可以分成三層來思考。
第一道防線就是到目前為止的設計原則本身。在審查時,用表格確認「哪些是共享的可變資料」「各自由哪個互斥鎖守護」「多個鎖的取得順序是否唯一(或是否用 scoped_lock 一併取得)」「停止路徑在哪裡」。若寫不出這張表,即使程式能動,設計也還沒完成。
第二,不隱藏異常,讓其可被觀測。在不應該取不到的鎖上,用 timed_mutex 的 try_lock_for 或 condition_variable 的 wait_for 加上逾時,把逾時當作異常記錄到日誌中,就能把永遠的卡死轉變成可偵測的失敗。在執行緒邊界的 try/catch(第6章)中接住的例外務必記錄下來。發生卡死或當機時,要採集傾印檔,確認所有執行緒的堆疊,檢查彼此的鎖等待是否形成循環。傾印檔與日誌的整備,在「Windows 應用程式當機時保留日誌與傾印檔的設計」中有處理。
第三,施加負載加以搖晃。用超過核心數的平行度長時間執行、將處理順序隨機化、插入人工延遲,這類壓力測試,是在開發機上更容易「抽中」競爭的現實手段。有些在偵錯組建中會消失的臭蟲,在最佳化的發行組建加上高負載下,經常能夠重現。
9. 總結 ── C++ 版檢查清單
在語言共通的原則(不直接建立執行緒・將共享可變狀態最小化・鎖與資料一對一對應・協同停止)之上,再疊加 C++ 特有的檢查項目。
- 是否有裸用
std::thread(能否改成jthread,即使走例外路徑join也有保證嗎) - 是否有使用
detach() - lambda 擷取是否明確,以參照擷取的變數,其生命週期是否長於執行緒
- 是否能斷言沒有任何一處未同步的共享可變存取(=未定義行為)
- 是否沒有手寫
lock()/unlock(),多個鎖是否以scoped_lock一併取得 condition_variable::wait是否全部都帶有述詞- 共享旗標是否沒有使用
volatile(是否已改為std::atomic) - 停止路徑是否以
stop_token(或 atomic 旗標 + 通知)設計,並以 join 確認合流完成 std::async的 future 是否沒有被捨棄- 是否沒有在
DllMain中進行執行緒的啟動・同步・join
C++ 的多執行緒是一項緊挨著「未定義行為」這座懸崖行走的工作,但反過來說,只要老實地遵循 RAII 與標準函式庫的作法,就能與懸崖拉開很大的距離。jthread・scoped_lock・帶述詞的 wait・atomic ── 正確選擇工具的預設值,在 C++ 中就是設計原則的實踐本身。
相關文章
- 多執行緒的實務最佳實踐 .NET篇 ── 在增加執行緒之前先決定的事
- 多執行緒實務最佳實踐 C 語言篇 ── 以 Win32 API 的方式安全撰寫
- 多執行緒實務最佳實踐 Java 篇 ── 虛擬執行緒時代的定石
- 要在 C# 中使用原生 DLL,C++/CLI 包裝是有力選項的理由 - 與 P/Invoke 的比較整理
- 使用共享記憶體時的陷阱與最佳實踐 - 先整理同步、可見性、壽命、ABI、安全性
- COM STA/MTA 基礎 - 執行緒模型與避免 Hang 的思考方式
- Windows I/O 的深層(第 2 回) ── 同步 I/O 與非同步 I/O:OVERLAPPED 的真正含義
相關諮詢領域
合同會社小村軟體承接 C++ 製應用程式・DLL 的多執行緒設計審查、「偶爾當機・只有在發行組建下才不對勁」這類由競爭引起的缺陷調查(傾印檔分析),以及舊有執行緒程式碼遷移至現代 C++ 的諮詢。
參考連結
-
ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism。關於並行・平行章節開頭列出的規則 CP.1(假設自己的程式碼會在多執行緒下執行)與 CP.2(避免資料競爭),資料競爭一旦發生就沒有任何保證能夠成立,以及鎖的持有範圍、RAII 的運用等並行程式碼的設計規則已被體系化的說明。 ↩ ↩2
-
cppreference.com, std::jthread。關於 C++20 的 jthread 與 std::thread 不同,會在解構函式中自動呼叫 request_stop() 後再 join,執行緒函式可以將 std::stop_token 作為第一個參數接收,並藉此即使發生例外,執行緒的合流與停止要求也能獲得保證的說明。 ↩ ↩2
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version。關於 P0660R10(<stop_token> 與 jthread)以及 P1135R6(C++20 同步函式庫)在 Visual Studio 2019 16.9 中獲得支援,以及 C++ 標準函式庫功能各版本支援狀況的說明。 ↩ ↩2 ↩3
-
Microsoft Learn, scoped_lock Class。關於 C++17 的 scoped_lock 會在建構時取得一個以上的互斥鎖,並在解構函式中釋放,若傳入多個互斥鎖,則會以相當於 std::lock 的死鎖迴避演算法取得,即使拋出例外也能確實釋放,若只有單一互斥鎖則 lock_guard/unique_lock 也是選項的說明。 ↩ ↩2 ↩3
-
Microsoft Learn, <atomic>。關於原子操作因不可分割,其他執行緒只能觀測到操作前或操作後的狀態,依 memory_order 引數建立對其他原子操作可見性的順序性要求,並抑制違反該要求的編譯器最佳化,atomic_flag 永遠是無鎖的,以及在 /clr:pure 下這個標頭會被封鎖的說明。 ↩ ↩2
-
Microsoft Learn, <condition_variable>。關於條件變數的等待需要互斥鎖,等待期間鎖會被釋放,由於存在沒有通知卻醒來的虛假喚醒,等待方應在恢復時明確確認條件,帶述詞的 wait(lock, pred) 會代為執行該迴圈,以及 condition_variable_any 可與任意互斥鎖型別搭配使用的說明。 ↩ ↩2
-
Microsoft Learn, <future>。關於 future 與 shared_future 的解構函式原則上不會阻塞,但唯一的例外是與 std::async 啟動的工作綁定的 future(或最後一個 shared_future),若在工作尚未完成時解構函式就執行,會阻塞到共享狀態變為 ready 為止,而這項行為已明確記載於規格的注記中的說明。 ↩ ↩2
-
Microsoft Learn, About Synchronization。關於 Win32 同步基元的選擇指引,重視可移植性的 C++ 程式碼建議使用 std::mutex / std::shared_mutex 與 RAII,需要 Win32 等待 API 或跨處理序同步時使用 Win32 同步物件,處理序內新程式碼的預設是 SRW 鎖,只有需要再入取得時才用 CRITICAL_SECTION,處理序內同步使用 Mutex 是總是伴隨核心轉換的「常見錯誤」的說明。 ↩ ↩2 ↩3
-
cppreference.com, std::thread::~thread。關於 std::thread 的解構函式,在執行緒仍為 joinable(既未 join 也未 detach)的狀態下被呼叫時會呼叫 std::terminate,也就是在執行緒物件銷毀之前,必須先完成 join 或 detach 的判斷的說明。 ↩
-
Microsoft Learn, Best Practices in the Parallel Patterns Library。關於平行化應盡可能在較高的層級(外層迴圈)表達,在每次迭代工作量小・不均衡的平行迴圈中,fork/join 的排程額外負擔可能超過平行執行帶來的收益,而這個傾向會隨處理器數量增加而加劇的說明。 ↩
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version。關於 C++17 的平行演算法函式庫雖已完成,但「完成」並不代表所有演算法在所有情況下都會被平行化,而是最重要的演算法已平行化,未平行化的部分也提供執行策略簽名的實作方針的說明。 ↩
-
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)的說明。 ↩
-
Microsoft Learn, Dynamic-Link Library Best Practices。關於 DllMain 是在持有載入器鎖的狀態下被呼叫,因此可呼叫的 API 有重大限制,在 DllMain 內與其他執行緒同步可能導致死鎖,呼叫 LoadLibrary 或等待執行緒結束是典型的禁止事項,初始化應盡量延後並移到 DllMain 之外,以及應定義鎖階層並將載入器鎖置於最上層的說明。 ↩
-
Microsoft Learn, <thread>。關於 <thread> 標頭定義了 thread 類別與 sleep_for 等輔助函式,以 /clr 編譯的程式碼中這個標頭會被封鎖,以及可透過 STDCPP_THREADS 巨集判斷是否支援執行緒的說明。 ↩
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
多執行緒實務最佳實踐 C 語言篇 ── 以 Win32 API 的方式安全撰寫
C 語言 × Win32 的多執行緒有其定式:以 _beginthreadex 建立執行緒、SRW 鎖與條件變數、Interlocked、以停止事件 + WaitForMultipleObjects 設計停止流程。本文一併整理 TerminateThread 的危險性與 D...
多執行緒的實務最佳實踐 .NET篇 ── 在增加執行緒之前先決定的事
針對 .NET/C# 整理能防止「建立執行緒後偶爾當機、卡死」的設計定石。內容涵蓋不自行建立執行緒而改用 Task、減少共享可變狀態、鎖的紀律、以 CancellationToken 設計停止機制,以及 UI 執行緒的處理方式。
多執行緒實務最佳實踐 Java 篇 ── 虛擬執行緒時代的定石
Java 的多執行緒開發,定石是不直接建立執行緒,而是交給 ExecutorService 與虛擬執行緒。本文整理 synchronized 與 ReentrantLock 的用法區分、透過中斷實現協調式停止、ConcurrentHashMap 的原子操作,以及 Swing...
磁碟區陰影複製服務(VSS)的機制與實務 ── 為什麼能備份使用中的檔案
明明使用中的檔案會因共用違規而無法複製,備份軟體卻為什麼辦得到?本文解說磁碟區陰影複製服務(VSS)中要求者・寫入器・提供者的角色分工、寫入時複製的機制、vssadmin 的實務操作與差異區的陷阱。
在 C#・PowerShell 中使用 WMI/CIM ── 硬體資訊取得・處理程序監控・遠端查詢的實務指南
取得 PC 序號、監控磁碟可用空間、偵測處理程序啟動,這些定番需求的標準答案就是 WMI/CIM。本文解說 Get-CimInstance 等 CIM Cmdlet 的用法、從舊版 Get-WmiObject 的遷移方式、C# 的 System.Management 與 C...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
技術諮詢 & 設計審查
協助釐清設計方向、架構邊界、生命週期責任,以及既有 Windows 資產的處理方式。
常見問題
整理諮詢這個主題時常見的問題。
- 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。