Item 37:讓 std::thread 在所有路徑上皆為 unjoinable (Make std::threads unjoinable on all paths)
Overview Table
| 重點 | 說明 |
|---|---|
| 核心規則 | std::thread 離開 scope 的每一條路徑上都必須是 unjoinable,否則程式直接終止 |
| joinable 解構的後果 | 解構 joinable 的 std::thread → std::terminate(標準委員會刻意的設計) |
| implicit join 的問題 | 若解構子隱式 join → 難以除錯的效能異常(函式莫名等待背景工作完成) |
| implicit detach 的問題 | 若解構子隱式 detach → 難以除錯的未定義行為(背景執行緒寫入已被回收的 stack 記憶體) |
| 標準解法 | 自行撰寫 RAII 類別(如 ThreadRAII),在解構子中依指定動作 join 或 detach |
| 成員宣告順序 | std::thread 資料成員應宣告在最後——建構後可能立即執行,須確保其他成員已初始化 |
Joinable 與 Unjoinable 狀態
Joinable:std::thread 對應到一條底層的非同步執行緒(執行中、被阻塞、等待排程、甚至已跑完都算 joinable)。
Unjoinable 的四種情況:
| 情況 | 原因 |
|---|---|
預設建構的 std::thread |
沒有函式可執行,不對應任何底層執行緒 |
被 move 走的 std::thread |
底層執行緒的對應關係已轉移給另一個 std::thread |
已 join 的 std::thread |
join 之後不再對應已結束的底層執行緒 |
已 detach 的 std::thread |
detach 切斷了與底層執行緒的連結 |
┌────────────────────┐
std::thread t(func) ──▶ │ joinable │ ──解構子被呼叫──▶ std::terminate!
└────────┬───────────┘ (程式終止)
│
join() / detach() / 被 move 走
│
▼
┌────────────────────┐
│ unjoinable │ ──解構子被呼叫──▶ 安全,正常解構
└────────────────────┘
「所有路徑」包含:正常流到 scope 結尾、return、continue、break、goto、丟出例外。任何一條路徑漏掉,joinable thread 的解構就會終止整個程式。
為何解構 joinable thread 要終止程式?
以 doWork 為例:lambda 以 reference 捕獲區域變數 goodVals 並在背景執行緒中 push_back;若 conditionsAreSatisfied() 回傳 false 或丟出例外,t 在解構時仍為 joinable:
constexpr auto tenMillion = 10'000'000; // C++14 可用 ' 作數字分隔符
bool doWorkfunction<bool(int)> filter, int maxVal = tenMillion
{
std::vector<int> goodVals; // 通過 filter 的值
std::thread t([&filter, maxVal, &goodVals] // 背景執行緒填入 goodVals
{
for (auto i = 0; i <= maxVal; ++i)
{ if (filter(i)) goodVals.push_back(i); }
});
auto nh = t.native_handle(); // 用 native handle 設定優先權
// ... //(futures API 拿不到,故用 thread)
if (conditionsAreSatisfied()) {
t.join(); // 正常路徑:join 後 unjoinable
performComputation(goodVals);
return true;
}
return false; // 問題路徑!t 仍 joinable → 解構 → 程式終止
}
委員會權衡過另外兩個選項,結論是都更糟:
| 解構子預設行為 | 後果 |
|---|---|
| implicit join | doWork 明明已判定不需計算,卻仍等 filter 跑完千萬筆——反直覺的效能異常,極難追查 |
| implicit detach | doWork 返回後 stack frame 被回收,背景 lambda 繼續對「曾是 goodVals」的記憶體 push_back;後續函式 f 的 stack 內容憑空改變——除錯噩夢級的未定義行為 |
| 終止程式(實際採用) | 後果明確、立即可見,被認為是「爛選項中最不爛的」 |
ThreadRAII:用 RAII 保證所有路徑 unjoinable
要在「離開 block 的每條路徑」執行同一動作,標準手法就是放進區域物件的解構子(RAII)。標準庫沒有為 std::thread 提供 RAII 類別,需自己寫:
class ThreadRAII {
public:
enum class DtorAction { join, detach }; // scoped enum,見 Item 10
ThreadRAIIthread&& t, DtorAction a // 只收 rvalue:thread 不可複製,
: action(a), tmove(t) {} // 必須 move 進來
~ThreadRAII()
{
if (t.joinable()) { // 必要檢查!對 unjoinable thread
if (action == DtorAction::join) { // 呼叫 join/detach 是 UB
t.join();
} else {
t.detach();
}
}
}
ThreadRAII(ThreadRAII&&) = default; // 宣告了解構子會抑制 move 生成,
ThreadRAII& operator=(ThreadRAII&&) = default; // 故顯式 =default(見 Item 17)
std::thread& get() { return t; } // 仿智慧指標的 get(),
// 免去複製整套 thread 介面
private:
DtorAction action;
std::thread t; // 刻意宣告在最後!
};
設計要點:
- 建構子只接受
std::thread&&:std::thread不可複製,只能 move 進 RAII 物件。 - 成員初始化列順序配合成員宣告順序;
std::thread成員宣告在最後——thread 初始化後可能立刻開跑,宣告在最後可保證它執行時,其前面的所有成員都已初始化、可安全存取。這是通用的好習慣。 - 解構子先測
t.joinable():使用者可能已透過get()對t做過 join / detach / move,使其 unjoinable。 - 在
doWork中以ThreadRAII tthread([...]{...}), ThreadRAII::DtorAction::join);取代裸std::thread,並改用t.get(存取,即可讓return false與例外路徑都安全。
「解構子中 joinable() 檢查與 join()/detach() 之間有 race」是錯誤直覺:std::thread 只能透過成員函式呼叫(join、detach、move)改變 joinability。若解構子執行時真有其他執行緒同時呼叫 t 的成員函式,race 存在於呼叫端程式碼(同時對同一物件呼叫兩個成員函式),而非 ThreadRAII 內部——同一物件的並行成員函式呼叫只有在全部是 const 成員函式時才安全(見 Item 16)。
選擇 DtorAction::join 只是「爛選項中最好的」:Item 39 展示 join-on-destruction 不只可能造成效能異常,甚至可能程式懸住 (hang)。「正確」解法是通知非同步 lambda 提前返回,但 C++11 沒有 interruptible threads(可手工實作,超出本書範圍)。
Exam/Test Patterns
| 情境關鍵字 | 答案 |
|---|---|
解構 joinable 的 std::thread 會怎樣? |
程式終止(std::terminate),不是 UB、不是隱式 join/detach |
哪些 std::thread 是 unjoinable? |
預設建構、被 move 走、已 join、已 detach |
已跑完的底層執行緒,其 std::thread 是否 joinable? |
是——run to completion 仍算 joinable,解構前仍須 join |
| 為何標準不採 implicit join? | 難以除錯的效能異常(明明不需要結果卻苦等背景工作) |
| 為何標準不採 implicit detach? | 難以除錯的未定義行為(背景執行緒寫入已回收的 stack frame) |
| 如何保證所有離開路徑(return / break / 例外…)都 unjoinable? | RAII 物件(如 ThreadRAII),把動作放在解構子 |
對 unjoinable thread 呼叫 join()/detach()? |
未定義行為——所以 ThreadRAII 解構子必先測 t.joinable() |
std::thread 資料成員應宣告在哪? |
類別成員列表最後(建構後可能立即執行,須確保其他成員已初始化) |
ThreadRAII 宣告了解構子,move 操作怎麼辦? |
解構子抑制編譯器生成 move,需 =default 顯式要回(Item 17) |
為何不用 task(std::async)而用 std::thread? |
需要 native_handle() 設定優先權等——futures API 不提供 |
Related Notes
- 08-Concurrency-API/01-Task-Based-Vs-Thread-Based — Item 35:能用 task 就不要裸用 thread;本 Item 是「必須用 thread 時」的安全守則
- 08-Concurrency-API/02-Std-Launch-Async — Item 36:以
std::launch::async確保真正的非同步 - 08-Concurrency-API/04-Thread-Handle-Destructor-Behavior — Item 38:future 的解構子行為與
std::thread大不相同 - 08-Concurrency-API/05-Void-Futures-For-Events — Item 39:如何讓 thread 以暫停狀態啟動;join-on-destruction 造成 hang 的案例
- 04-Moving-To-Modern-Cpp/11-Special-Member-Function-Generation — Item 17:宣告解構子會抑制 move 操作的自動生成
- 04-Moving-To-Modern-Cpp/04-Scoped-Enums — Item 10:
DtorAction使用enum class的理由 - 04-Moving-To-Modern-Cpp/10-Const-Member-Functions-Thread-Safety — Item 16:同一物件的並行成員函式呼叫僅 const 成員函式安全