每個std::thread對象的狀態都是這兩種中的一種:joinable(可連接的)或unjoinable(不可連接的)。一個可連接的std::thread對應一個底層異步執行線程,例如,一個std::thread對應的一個底層線程,它會被阻塞或等待被調度,那麼這個std::thread就是可連接的。std::thread對象對應的底層線程可以將代碼運行至結束,也可將其視為可連接的。
不可連接的std::thread的意思就如你想象那樣:std::thread不是可連接的。不可連接的std::thread對象包括:
默認構造的std::thread。這種std::thread沒有函數可以執行,因此沒有對應的底層執行線程。 被移動過的std::thread。移動的結果是,一個std::thread對應的底層執行線程被對應到另一個std::thread。 被連接過(調用了join)的std::thread。在調用了join之後,std::thread對應的底層執行線程結束運行,就沒有對應的底層線程了。 被分離(detach)的std::thread。detach把std::thread對象與它對應的底層執行線程分離開。std::thread的連接性是很重要的,其中一個原因是:如果一個可連接的線程對象執行了析構操作,那麼程序會被終止。例如,假設我們有一個函數doWork,它的參數包含過濾器函數
filter、一個最大值
maxVal。
doWork把0到
maxVal之間值傳給過濾器,然後滿足特定條件就對滿足過濾器的值進行計算。如果執行過濾器函數是費時的,而檢查條件也是費時的,那麼並發做這兩件事是合理的。
我們其實會更偏向於使用基於任務的設計(看條款35),但是讓我們假定我們想要設置執行過濾器線程的優先級。條款35解釋過請求使用線程的本機句柄(native handle)時,只能通過std::thread的API;基於任務的API沒有提供這個功能。因此我們的方法是基於線程,而不是基於任務。
我們可以提出這樣的代碼:
constexpr auto tenMillion = 10000000; // 關於constexpr,看條款15
bool doWork(std::function filter, // 返回是否會進行計算
int maxVal = tenMillion) // 關於std::function,看條款2
{
std::vector goodVals; // 滿足過濾器的值
std::thread t([&filter, maxVal, &goodVals])
{
for (auto i = 0; i <= maxVal; ++i)
{ if (filter(i) goodVals.push_back(i); }
});
auto nh = t.native_handle(); // 獲取t的本機句柄
... // 使用t的本機句柄設置t的優先級
if (conditionsAreSatisfied()) {
t.join(); // 等待t結束
performComputation(goodVals);
return true; // 會進行計算
}
return false; // 不會進行計算
}
在我解釋這個代碼為什麼有問題之前,我想提一下
tenMillion的初始值在C++14可以變得更有可讀性,利用C++14的能力,把單引號作為數字的分隔符:
constexpr auto tenMillion = 10'000'000; // C++14
我還想提一下在線程
t開始執行之後才去設置它的優先級,這有點像眾所周知的馬脫缰跑了後你才關上門。一個更好設計是以暫停狀態啟動線程
t(因此可以在執行之前修改它的優先級),但我不想那部分的代碼使你分心。如果你已經嚴重分心了,那麼請去看條款39,因為那裡展示了如何啟動暫停的線程。
回到
doWork,如果
conditionsAreSatisfied()返回true,那麼沒問題,但如果返回false或者拋出異常,那麼在
doWork的末尾,調用std::thread的析構函數時,它狀態是可連接的,那會導致執行中的程序被終止。
你可能想知道std::thread的析構函數為什麼會表現出這種行為,那是因為另外兩種明顯的選項會更糟。它們是:
隱式連接(join)。在這種情況下,std::thread的析構函數會等待底層異步執行線程完成工作。這聽起來合情合理,但是這會導致難以追蹤的性能異常。例如,如果conditionAreSatisfied()已經返回false了,doWork函數還要等待過濾器函數的那個循環,這是違反直覺的。 隱式分離(detach)。在這種情況下,std::thread的析構函數會分離std::thread對象與底層執行線程之間的連接,而那個底層執行線程會繼續執行。這聽起來和join那個方法一樣合理,但它導致更難調試的問題。例如,在doWork中,goodVals是個通過引用捕獲的局部變量,它可以在lambda內被修改(通過push_back),然後,假如當lambda異步執行時,conditionsAreSatisfied()返回false。那種情況下,doWork會直接返回,它的局部變量(包括goodVals)會被銷毀,doWork的棧幀會被彈出,但是線程仍然執行。
在接著
doWork調用端之後的代碼中,某個時刻,會調用其它函數,而至少一個函數可能會使用一部分或者全部
doWork棧幀占據過的內存,我們先把這個函數稱為
f。當
f運行時,
doWork發起的lambda依然會異步執行。lambda在棧上對
goodVals調用push_back,不過如今是在
f的棧幀中。這樣的調用會修改過去屬於
goodVals的內存,而那意味著從
f的角度看,棧幀上的內存內容會自己改變!想想看你調試這個問題時會有多滑稽。
標准委員會任務銷毀一個可連接的線程實在太恐怖了,所以從根源上禁止它(通過指定可連接的線程的析構函數會終止程序)。
這就把責任交給了你,如果你使用了一個std::thread對象,你要確保在它定義的作用域外的任何路徑,使它變為不可連接。但是覆蓋任何路徑是很復雜的,它包括關閉流出范圍然後借助return、continue、break、goto或異常來跳出,這有很多條路徑。
任何時候你想要在每一條路徑都執行一些動作,那麼最常用的方法是在局部對象的析構函數中執行動作。這些對象被稱為了RAII對象,而產生它們的類被稱為RAII類(RAII(Resource Acquisition Is Initialization)表示“資源獲取就是初始化”,即使技術的關鍵是銷毀,而不是初始化)。RAII類在標准庫很常見,例子包括STL容器(每個容器的析構函數都會銷毀容器的內容並釋放內存)、標准智能指針(條款18-20解釋了std::unique_ptr析構函數會對它指向的對象調用刪除器,而std::shared_ptr和std::weak_ptr的析構函數會減少引用計數)、std::fstream對象(它們的析構函數會關閉對應的文件),而且還有很多。然而,沒有關於std::thread的標准RAII類,可能是因為標准委員會拒絕把join或detach作為默認選項,這僅僅是不知道如何實現這樣類。
幸運的是,你自己寫一個不會很難。例如,下面這個類,允許調用者指定
ThreadRAII對象(一個std::thread的RAII對象)銷毀時調用join或者detach:
class ThreadRAII {
public:
enum class DtorAction { join, detach }; // 關於enum class,請看條款10
ThreadRAII(std::thread&& t, DtorAction a) // 在析構函數,對t采取動作a
: action(a), t(std::move(t)) {}
~ThreadRAII()
{
if (t.joinable()) { // 關於連接性測試,看下面
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
std::thread& get() { return t; }
private:
DtorAction action;
std::thread t;
};
我希望這份代碼是一目了然的,但下面的幾點可能對你有幫助:
構造函數只接受右值的std::thread,因為我們想要把傳進來的std::thread對象移動到ThreadRAII對象裡。(std::thread是不能被拷貝的類型。) 對於調用者,構造函數的形參順序的設計十分直觀(指定std::thread作為第一個參數,而銷毀動作作為第二個參數,比起反過來直觀很多),但是,成員初始化列表被設計來匹配成員變量聲明的順序,成員變量的順序是把std::thread放到最後。在這個類中,這順序不會導致什麼不同,不過一般來說,一個成員變量的初始化有可能依賴另一個成員變量,而因為std::thread對象初始化之後可能會馬上運行函數,所以把它們聲明在一個類的最後是一個很好的習慣。那保證了當std::thread構造的時候,所有在它之前的成員變量都已經被初始化,因此std::thread成員變量對應的底層異步執行線程可以安全地取得它們。 ThreadRAII提供了一個get函數,它是一個取得內部std::thread對象的入口,這類似於標准智能指針提供了get函數(它提供了取得內部原生指針的入口)。提供get可以避免ThreadRAII復制std::thread的所有接口,而這也意味著ThreadRAII可以用於請求std::thread對象的上下文。 ThreadRAII的析構函數在調用std::thread對象t的成員函數之前,它先檢查確保t是可連接的。這是必需的,因為對一個不可連接的線程調用join或detach會產生未定義行為。某個用戶構建了一個std::thread,然後用它創建ThreadRAII對象,再使用get請求獲得t,接著移動t或者對t調用join或detach,這是有可能發生的,而這樣的行為會導致t變得不可連接。
如果你擔心這代碼,
if (t.joinable()) {
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
存在競爭,因為在
t.joinable()和調用join或detach之間,另一個線程可能讓
t變得不可連接。你的直覺是值得贊揚的,但是你的害怕是沒有根據的。一個std::thread對象只能通過調用成員函數來從可連接狀態轉換為不可連接狀態,例如,join、detach或移動操作。當
ThreadRAII對象的析構函數被調用時,不應該有其他線程調用該對象的成員函數。如果這兩個函數同時發生,那的確是競爭,但競爭沒有發生在析構函數內,它是發生在試圖同時調用兩個成員函數(析構函數和其他)的用戶代碼內。一般來說,對於一個對象同時調用兩個成員函數,也只有是const成員函數(看條款16)才能確保線程安全。
在我們
doWork的例子中使用
ThreadRAII,代碼是這樣的:
bool doWork(std::function goodVals; // 如前
ThreadRAII t(
std::thread([&filter, maxVal, &goodVals) // 使用RAII對象
{
for (auto i = 0; i <= maxVal; ++i)
{ if (filter) goodVals.push_back(i); }
}),
ThreadRAII::DtorAction::join // RAII動作
);
auto nh = t.get().native_handle();
...
if (conditionsAreStatisfied()) {
t.get().join();
performConputation(goodVals);
return true;
}
return false;
}
在這個例子中,我們選擇在
ThreadRAII析構函數中,對異步執行線程調用join函數,因為我們之前看到,調用detach函數會導致一些惡夢般的調試。我們之前也看到過join會導致性能異常(實話說,那調試起來也很不爽),但在未定義行為(detach給的)、程序終止(使用原始std::thread會產生)、性能異常之前做出選擇,性能異常就像是瘸子裡面挑出的將軍 。
額,條款39展示了使用
ThreadRAII在std::thread銷毀中進行join不會導致性能異常,而是導致掛起程序。這種問題的“合適的”解決方案是:和異步執行的lambda進行交流,當我們不需要它時候,它可以早早的返回;但C++11不支持這種可中斷的線程。我們可以手動實現它們,但那個話題已經超越了這本書的范圍了(在《C++並發編程實戰》的章節9.2可以找到)。
條款17解釋過,因為
ThreadRAII聲明了析構函數,所以不會有編譯器生成的移動操作,但這裡
ThreadRAII對象沒有理由不能移動。如果編譯器生成的這些函數,這些函數的可以行為是正確的,所以顯示請求創建它們是適合的:
class ThreadRAII {
public:
enum class DtorAction { join, detach }; // 如前
ThreadRAII(std::thread&& t, DtorAction a) // 如前
: action(a), t(std::move(t)) {}
~ThreadRAII()
{
... // 如前
}
ThreadRAII(ThreadRAII&&) = default; // 支持移動
ThreadRAA& operator=(ThreadRAII&) = default;
std::thread& get() { return t; }
private:
DtorAction action;
std::thread t;
};
總結
需要記住的4點:
- 在所有路徑上,讓std::thread變得不可連接。
- 在銷毀時用join會導致難以調試的性能異常。
- 在銷毀時用detach會導致難以調試的未定義行為。
- 在成員變量列表最後聲明std::thread。