每個
std::thread
型别對象皆處于兩種狀态之一:可聯結或不可聯結。
哪些屬于可聯結
可聯結的
std::thread
對應底層以異步方式已運作或者可運作的線程。
std::thread
型别對象對應的底層線程若處于阻塞或等待排程,則它可聯結。
std::thread
型别對象對應的底層線程如已運作結束,則也認為其可聯結。
哪些屬于不可聯結
不可聯結的
std::thread
的意思如你所想:
std::thread
不處于以上可聯結的狀态。不可聯結的
std::thread
型别對象包括:
- 預設構造的std::thread。此類
沒有可以執行的函數,是以也沒有對應的底層執行線程。std::thread
- 已移動的std::thread。移動操作的結果是,一個
所對應的底層執行線程(若有)被對應到另外一個std::thread
。std::thread
- 已聯結的std::thread。聯結後,
型别對象不再對應至已結束運作的底層執行線程。std::thread
- 已分離的std::thread。分離操作會把
型别對象和它對應的底層執行線程之間的連接配接斷開。std::thread
為何要求不可聯結的直接原因
std::thread
可聯結性之是以重要的原因之一是:如果可聯結的線程對象的析構函數被調用,則程式的執行就終止了。
舉個栗子說明
舉個栗子,假設我們有一個函數
doWork
,它接受一個篩選器函數
filter
和一個最大值
maxVal
作為形參。
doWork
會校驗它做計算的條件全部成立,然後會針對篩選器選出的
到
maxVal
之間的值實施計算。如果篩選是費時的,而條件檢驗也是費時的,那麼并發地做這兩件事就是合理的。
我們會優先選用基于任務的設計(參見Item 35),但是讓我們假定會去設定實施篩選的那個線程的優先級。Item 35解釋過,這要求使用線程的低級句柄,進而隻能通過
std::thread
的API來通路。基于任務的API(如期值)沒有提供這個功能。
是以,我們隻有采用基于線程的方式,基于任務在此不可行。
我們可能會撰寫出這樣的代碼:
constexpr auto tenMillion = 10000000; //關于constexpr參見Item 15
bool doWork(std::function<bool(int)> filter, //傳回值代表計算是否執行了
int maxVal = tenMillion) //關于function參見Item2
{
std::vector<int> 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的優先級
...
if (conditionsAreSatisfied()) {
t.join(); //讓t結束執行
performComputation(goodVals);
return true; //計算已實施
}
return false; //計算未實施
}
以上的原型代碼有諸多問題,以下一一道來:
1. 更有可讀性的寫法
在C++14中,單引号可以作為數字分隔符:
2. 線程開始之前設定優先級
示例代碼中線上程t開始執行之後才去設定它的優先級,這有點像,烈馬已經脫缰跑走後才關上馬廄的門。更好的設計是以暫停狀态啟動線程t。具體代碼執行個體可以在Item39中進行查閱。這裡為了不偏離主題,隻是提到執行個體中的設計不好。
3. 核心問題的地方
如果
conditionsAreSatisfied()
傳回
true
,則一切都好;但如果它傳回
false
或抛出異常,那麼在
doWork
的末尾調用
std::thread
型别對象t的析構函數是,它會處于可聯結狀态,進而導緻程式執行終止。
你可能會想知道,
std::thread
的析構函數為何會這樣運作。不終止程式難道不行嗎??
講講為什麼析構函數要這麼做
因為别無選擇,其他的選擇比這麼做更加糟糕,分别是:
- 隐式join。在這種情況下,
的析構函數會等待底層異步執行線程完成。這聽上去合理,但卻可能導緻難以追蹤的性能異常。例如,如果std::thread
早已傳回conditionsAreSatisfied()
了,false
卻還在等待所有值上周遊篩選,這是違反直覺的。doWork
- 隐式detach。在這種情況下,
的析構函數會分離std::thread
型别對象與底層執行線程之間的連接配接。而該底層執行線程會繼續執行。這聽起來和std::thread
途徑相比在合理性方面并不遜色,但它導緻的調試問題會更加要命。例如,在join
内doWork
是個通過引用捕獲的局部變量,它會在goodVals
式内被修改(通過對lambda
的調用)。然後,假如lambda式以異步方式運作時,push_back
傳回了conditionsAreSatisfied()
。那種情況下,false
會直接傳回,它的局部變量(包括doWork
)會被銷毀,goodVals
的棧幀會被彈出,可是線程卻仍然在doWork
的調用方繼續運作着。doWork
在
doWork
調用方的後續語句中,在某個時刻,會調用其他函數,而至少會有一個函數可能會使用一部分或者全部
doWork
棧幀占用過的記憶體,假設這個函數稱為
f
。當
f
運作時,
doWork
發起的lambda式依然在異步執行。該lambda式在原先的棧上對
goodVals
調用
push_back
,不過那已是在
f
的棧幀中了。這樣的調用會修改過去屬于
goodVals
的記憶體,而那意味着從
f
的視角看,棧幀上的記憶體内容會莫名其妙的被改變!想想看那樣的問題,會多麼酸爽。
綜上所述,标準委員會意識到,銷毀一個可聯結的線程是在太過可怕,是以實際上已經封印了這件事(通過規定可聯結的現成的析構函數導緻程式異常終止)。
你要關心的地方來了
既然标準委員會把抉擇權利給了你,如果你使用了
std::thread
型别對象,就必須確定從它定義的作用域出去的任何路徑,使它成為不可聯結狀态。但是覆寫所有路徑是複雜的,這包括正常走完作用域,還有經由
return
,
continue
,
break
,
goto
或異常跳出作用域。路徑何其多。
任何時候,隻要想在每條出向路徑上都執行某動作,最常用的方法就是在局部對象的析構函數中執行該動作。這樣的對象稱為
RAII
對象,它們來自
RAII
類(
RAII
本身代表
Resource Acquistion Is Initialzation
,資源擷取即初始化,即使該技術的關鍵其實在于析構而非初始化)。
RAII
類在标準庫中很常見,例如STL容器(各個容器的析構函數都會析構容器内容并釋放其記憶體)、标準智能指針(Item 18, Item 19, Item 20解釋過,
std::unique_ptr
的析構函數會對它指涉的對象調用删除容器,而
std::shared_ptr
和
std::weak_ptr
的析構函數會對引用計數實施自減)、
std::fstream
型别對象(其析構函數會關閉對應的檔案),還有很多。然而,沒有和
std::thread
型别對象對應的标準
RAII
類,可能是因為标準委員會把join或detach用做預設選項的途徑都堵死了,這麼一來也就不知道真有這樣的類的話該如何運作。
自己寫一個 RAII
的 std::thread
RAII
std::thread
幸運的是,自己寫一個也并不難。例如,下面這個類就允許調用者指定
ThreadRAII
型别對象(它是個
std::thread
對應的
RAII
對象)銷毀時調用
join
還是
detach
:
class ThreadRAII {
public:
enum class DtorAction {join, detach}; //關于枚舉類,參見Item 10
ThreadRAII(std::thread&& t, DtorAction a) //在析構函數中
: action(a), t(std::move(t)) {} //在 t 上采取行動a
~ThreadRAII()
{
if (t.joinable()) { //可聯結性測試見下
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
}
std::thread& get() {return t;} //見下
private:
DtorAction action;
std::thread t;
};
對于自己建構的類注意一下幾點
我希望這段代碼基本上不言自明,但指出以下幾點可能會有幫助:
1. std::thread
是隻移型别
std::thread
- 構造函數隻接受右值型别的
,因為我們想要把傳入的std::thread
型别對象移入std::thread
對象(提醒一下,ThreadRAII
是不可複制的)。std::thread
2. 類中成員的聲明順序有講究
- 讀/寫哪個線程的
變量并無影響。或者可以給出保證在thread_local
傳回的期值之上調用std::async
或get
,或者可以接受任務可能永不執行。使用wait
或wait_for
的代碼會将任務被推遲的可能性納入考量。構造函數的形參順序的設計對于調用者而言是符合直覺的(指定wait_until
作為第一個形參,而銷毀行動作為第二個參數,比相反順序更直覺),但是,成員初始化清單的設計要求它比對成員變量聲明的順序,而後者是把std::thread
的順序放到靠後的。在本類中,順序不會導緻不同,但作為一般讨論,一個成員變量的初始化有可能會依賴另一個成員變量,又因為std::thread
型别對象初始化後可能會馬上用來運作函數,是以把它們聲明在類的最後是個好習慣。這保證了當std::thread
型别對象在構造之時,所有在它之前的成員變量都已經完成了初始化,因而std::thread
成員變量對應的底層異步執行線程可以安全地通路它們了。std::thread
3. 提供一個 get
避免複寫接口
get
-
提供了一個ThreadRAII
函數,用以通路底層的get
型别對象。這和标準智能指針提供的std::thread
函數一樣(後者用以通路底層裸指針)。提供get
可以避免讓get
去重複ThreadRAII
的所有接口,也意味着std::thread
型别對象可以用于需要直接使用ThreadRAII
型别對象的語境。std::thread
4. 析構的時候判斷可聯結性很重要
-
的析構函數在調用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
成員函數時才安全(參見Item 16)。
在我們的
doWork
一例中運用
ThreadRAII
,代碼會長成這樣:
bool doWork(std::function<bool(int)> filter, //同前
int maxVal = tenMillion)
{
std::vector<int> goodVals;
ThreadRAII t(
std::thread([&filter, maxVal, &goodVals]
{
for (auto i = 0; i <= maxVal; ++i)
{ if (filter(i)) goodVals.push_back(i); }
}),
ThreadRAII::DtorAction::join
);
auto nh = t.get().native_handle();
...
if (conditionsAreSatisfied()) {
t.get().join();
performComputation(goodVals);
return true;
}
return false;
}
在該例子中,我們選擇在
ThreadRAII
析構函數中對異步執行線程調用
join
。因為我們之前已經看到了,調用
detach
函數會導緻噩夢般的調試。我們之前也看過
join
會導緻性能異常(實話實說,
join
的調試也絕不令人愉悅),但未定義行為(
detach
導緻的)、程式終止(使用裸
std::thread
産生的)和性能異常之間做出選擇,性能異常也是權衡之下的弊端最小的一個。
可遺憾的是,Item 39會戰士,使用
ThreadRAII
在
std::thread
析構中實施
join
不是僅僅會導緻性能異常那麼簡單,而是會導緻程式失去響應。這種問題的“合适的”解決方案是和異步執行的lambda式通信,當我們已經不再需要它運作,它應該提前傳回,但C++11中并不支援這種可中斷線程。我們可以手動實作它,但這個話題不展開,可以在《C++ Concurrency in Action》的9.2節中找到答案。
Item 17解釋過,因為
ThreadRAII
聲明了析構函數,是以不會有編譯器生成的 移動操作,但這裡
ThreadRAII
對象沒有理由實作為不可移動的。如果編譯器會生成這些函數,這些函數的行為就是正确的,是以顯示地請求建立它們是适當的:
class ThreadRAII {
public:
enum class DtorAction {join, detach}; //同前
ThreadRAII(std::thread&& t, DtorAction a) //同前
: action(a), t(std::move(t)) {} //同前
~ThreadRAII()
{
if (t.joinable()) { //同前
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
}
ThreadRAII(ThreadRAII&& ) = default; //支援移動構造
ThreadRAII& operator=(ThreadRAII&&) = default; //支援移動指派
std::thread& get() {return t;} //同前
private:
DtorAction action;
std::thread t;
};
要點速記 |
---|
1. 使 型别對象在所有路徑皆不可聯結。 |
2. 在析構時調用 可能導緻難以調試的性能異常。 |
3. 在析構時調用 可能導緻難以調試的未定義行為。 |
4. 在成員清單的最後聲明 型别對象。 |