每個程序至少有一個線程:執(zhí)行main()函數(shù)的線程,其余線程有其各自的入口函數(shù)。線程與原始線程(以main()為入口函數(shù)的線程)同時運行。如同main()函數(shù)執(zhí)行完會退出一樣,當(dāng)線程執(zhí)行完入口函數(shù)后,線程也會退出。在為一個線程創(chuàng)建了一個std::thread
對象后,需要等待這個線程結(jié)束;不過,線程需要先進行啟動。下面就來啟動線程。
第1章中,線程在std::thread
對象創(chuàng)建(為線程指定任務(wù))時啟動。最簡單的情況下,任務(wù)也會很簡單,通常是無參數(shù)無返回的函數(shù)。這種函數(shù)在其所屬線程上運行,直到函數(shù)執(zhí)行完畢,線程也就結(jié)束了。在一些極端情況下,線程運行時,任務(wù)中的函數(shù)對象需要通過某種通訊機制進行參數(shù)的傳遞,或者執(zhí)行一系列獨立操作;可以通過通訊機制傳遞信號,讓線程停止。線程要做什么,以及什么時候啟動,其實都無關(guān)緊要??傊?,使用C++線程庫啟動線程,可以歸結(jié)為構(gòu)造std::thread
對象:
void do_some_work();
std::thread my_thread(do_some_work);
為了讓編譯器識別std::thread
類,這個簡單的例子也要包含<thread>
頭文件。如同大多數(shù)C++標(biāo)準(zhǔn)庫一樣,std::thread
可以用可調(diào)用類型構(gòu)造,將帶有函數(shù)調(diào)用符類型的實例傳入std::thread
類中,替換默認(rèn)的構(gòu)造函數(shù)。
class background_task
{
public:
void operator()() const
{
do_something();
do_something_else();
}
};
background_task f;
std::thread my_thread(f);
代碼中,提供的函數(shù)對象會復(fù)制到新線程的存儲空間當(dāng)中,函數(shù)對象的執(zhí)行和調(diào)用都在線程的內(nèi)存空間中進行。函數(shù)對象的副本應(yīng)與原始函數(shù)對象保持一致,否則得到的結(jié)果會與我們的期望不同。
有件事需要注意,當(dāng)把函數(shù)對象傳入到線程構(gòu)造函數(shù)中時,需要避免“最令人頭痛的語法解析”(C++’s most vexing parse, 中文簡介)。如果你傳遞了一個臨時變量,而不是一個命名的變量;C++編譯器會將其解析為函數(shù)聲明,而不是類型對象的定義。
例如:
std::thread my_thread(background_task());
這里相當(dāng)與聲明了一個名為my_thread的函數(shù),這個函數(shù)帶有一個參數(shù)(函數(shù)指針指向沒有參數(shù)并返回background_task對象的函數(shù)),返回一個std::thread
對象的函數(shù),而非啟動了一個線程。
使用在前面命名函數(shù)對象的方式,或使用多組括號①,或使用新統(tǒng)一的初始化語法②,可以避免這個問題。
如下所示:
std::thread my_thread((background_task())); // 1
std::thread my_thread{background_task()}; // 2
使用lambda表達式也能避免這個問題。lambda表達式是C++11的一個新特性,它允許使用一個可以捕獲局部變量的局部函數(shù)(可以避免傳遞參數(shù),參見2.2節(jié))。想要具體的了解lambda表達式,可以閱讀附錄A的A.5節(jié)。之前的例子可以改寫為lambda表達式的類型:
std::thread my_thread([]{
do_something();
do_something_else();
});
啟動了線程,你需要明確是要等待線程結(jié)束(加入式——參見2.1.2節(jié)),還是讓其自主運行(分離式——參見2.1.3節(jié))。如果std::thread
對象銷毀之前還沒有做出決定,程序就會終止(std::thread
的析構(gòu)函數(shù)會調(diào)用std::terminate()
)。因此,即便是有異常存在,也需要確保線程能夠正確的加入(joined)或分離(detached)。2.1.3節(jié)中,會介紹對應(yīng)的方法來處理這兩種情況。需要注意的是,必須在std::thread
對象銷毀之前做出決定,否則你的程序?qū)K止(std::thread的析構(gòu)函數(shù)會調(diào)用std::terminate(),這時再去決定會觸發(fā)相應(yīng)異常)。
如果不等待線程,就必須保證線程結(jié)束之前,可訪問的數(shù)據(jù)得有效性。這不是一個新問題——單線程代碼中,對象銷毀之后再去訪問,也會產(chǎn)生未定義行為——不過,線程的生命周期增加了這個問題發(fā)生的幾率。
這種情況很可能發(fā)生在線程還沒結(jié)束,函數(shù)已經(jīng)退出的時候,這時線程函數(shù)還持有函數(shù)局部變量的指針或引用。下面的清單中就展示了這樣的一種情況。
清單2.1 函數(shù)已經(jīng)結(jié)束,線程依舊訪問局部變量
struct func
{
int& i;
func(int& i_) : i(i_) {}
void operator() ()
{
for (unsigned j=0 ; j<1000000 ; ++j)
{
do_something(i); // 1. 潛在訪問隱患:懸空引用
}
}
};
void oops()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread my_thread(my_func);
my_thread.detach(); // 2. 不等待線程結(jié)束
} // 3. 新線程可能還在運行
這個例子中,已經(jīng)決定不等待線程結(jié)束(使用了detach()②),所以當(dāng)oops()函數(shù)執(zhí)行完成時③,新線程中的函數(shù)可能還在運行。如果線程還在運行,它就會去調(diào)用do_something(i)函數(shù)①,這時就會訪問已經(jīng)銷毀的變量。如同一個單線程程序——允許在函數(shù)完成后繼續(xù)持有局部變量的指針或引用;當(dāng)然,這從來就不是一個好主意——這種情況發(fā)生時,錯誤并不明顯,會使多線程更容易出錯。
處理這種情況的常規(guī)方法:使線程函數(shù)的功能齊全,將數(shù)據(jù)復(fù)制到線程中,而非復(fù)制到共享數(shù)據(jù)中。如果使用一個可調(diào)用的對象作為線程函數(shù),這個對象就會復(fù)制到線程中,而后原始對象就會立即銷毀。但對于對象中包含的指針和引用還需謹(jǐn)慎,例如清單2.1所示。使用一個能訪問局部變量的函數(shù)去創(chuàng)建線程是一個糟糕的主意(除非十分確定線程會在函數(shù)完成前結(jié)束)。此外,可以通過join()函數(shù)來確保線程在函數(shù)完成前結(jié)束。
如果需要等待線程,相關(guān)的std::thread
實例需要使用join()。清單2.1中,將my_thread.detach()
替換為my_thread.join()
,就可以確保局部變量在線程完成后,才被銷毀。在這種情況下,因為原始線程在其生命周期中并沒有做什么事,使得用一個獨立的線程去執(zhí)行函數(shù)變得收益甚微,但在實際編程中,原始線程要么有自己的工作要做;要么會啟動多個子線程來做一些有用的工作,并等待這些線程結(jié)束。
join()是簡單粗暴的等待線程完成或不等待。當(dāng)你需要對等待中的線程有更靈活的控制時,比如,看一下某個線程是否結(jié)束,或者只等待一段時間(超過時間就判定為超時)。想要做到這些,你需要使用其他機制來完成,比如條件變量和期待(futures),相關(guān)的討論將會在第4章繼續(xù)。調(diào)用join()的行為,還清理了線程相關(guān)的存儲部分,這樣std::thread
對象將不再與已經(jīng)完成的線程有任何關(guān)聯(lián)。這意味著,只能對一個線程使用一次join();一旦已經(jīng)使用過join(),std::thread
對象就不能再次加入了,當(dāng)對其使用joinable()時,將返回false。
如前所述,需要對一個還未銷毀的std::thread
對象使用join()或detach()。如果想要分離一個線程,可以在線程啟動后,直接使用detach()進行分離。如果打算等待對應(yīng)線程,則需要細心挑選調(diào)用join()的位置。當(dāng)在線程運行之后產(chǎn)生異常,在join()調(diào)用之前拋出,就意味著這次調(diào)用會被跳過。
避免應(yīng)用被拋出的異常所終止,就需要作出一個決定。通常,當(dāng)傾向于在無異常的情況下使用join()時,需要在異常處理過程中調(diào)用join(),從而避免生命周期的問題。下面的程序清單是一個例子。
清單 2.2 等待線程完成
struct func; // 定義在清單2.1中
void f()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread t(my_func);
try
{
do_something_in_current_thread();
}
catch(...)
{
t.join(); // 1
throw;
}
t.join(); // 2
}
清單2.2中的代碼使用了try/catch
塊確保訪問本地狀態(tài)的線程退出后,函數(shù)才結(jié)束。當(dāng)函數(shù)正常退出時,會執(zhí)行到②處;當(dāng)函數(shù)執(zhí)行過程中拋出異常,程序會執(zhí)行到①處。try/catch
塊能輕易的捕獲輕量級錯誤,所以這種情況,并非放之四海而皆準(zhǔn)。如需確保線程在函數(shù)之前結(jié)束——查看是否因為線程函數(shù)使用了局部變量的引用,以及其他原因——而后再確定一下程序可能會退出的途徑,無論正常與否,可以提供一個簡潔的機制,來做解決這個問題。
一種方式是使用“資源獲取即初始化方式”(RAII,Resource Acquisition Is Initialization),并且提供一個類,在析構(gòu)函數(shù)中使用join(),如同下面清單中的代碼。看它如何簡化f()函數(shù)。
清單 2.3 使用RAII等待線程完成
class thread_guard
{
std::thread& t;
public:
explicit thread_guard(std::thread& t_):
t(t_)
{}
~thread_guard()
{
if(t.joinable()) // 1
{
t.join(); // 2
}
}
thread_guard(thread_guard const&)=delete; // 3
thread_guard& operator=(thread_guard const&)=delete;
};
struct func; // 定義在清單2.1中
void f()
{
int some_local_state=0;
func my_func(some_local_state);
std::thread t(my_func);
thread_guard g(t);
do_something_in_current_thread();
} // 4
當(dāng)線程執(zhí)行到④處時,局部對象就要被逆序銷毀了。因此,thread_guard對象g是第一個被銷毀的,這時線程在析構(gòu)函數(shù)中被加入②到原始線程中。即使do_something_in_current_thread拋出一個異常,這個銷毀依舊會發(fā)生。
在thread_guard的析構(gòu)函數(shù)的測試中,首先判斷線程是否已加入①,如果沒有會調(diào)用join()②進行加入。這很重要,因為join()只能對給定的對象調(diào)用一次,所以對給已加入的線程再次進行加入操作時,將會導(dǎo)致錯誤。
拷貝構(gòu)造函數(shù)和拷貝賦值操作被標(biāo)記為=delete
③,是為了不讓編譯器自動生成它們。直接對一個對象進行拷貝或賦值是危險的,因為這可能會弄丟已經(jīng)加入的線程。通過刪除聲明,任何嘗試給thread_guard對象賦值的操作都會引發(fā)一個編譯錯誤。想要了解刪除函數(shù)的更多知識,請參閱附錄A的A.2節(jié)。
如果不想等待線程結(jié)束,可以分離(_detaching)線程,從而避免_異常安全*(exception-safety)問題。不過,這就打破了線程與std::thread
對象的聯(lián)系,即使線程仍然在后臺運行著,分離操作也能確保std::terminate()
在std::thread
對象銷毀才被調(diào)用。
使用detach()會讓線程在后臺運行,這就意味著主線程不能與之產(chǎn)生直接交互。也就是說,不會等待這個線程結(jié)束;如果線程分離,那么就不可能有std::thread
對象能引用它,分離線程的確在后臺運行,所以分離線程不能被加入。不過C++運行庫保證,當(dāng)線程退出時,相關(guān)資源的能夠正確回收,后臺線程的歸屬和控制C++運行庫都會處理。
通常稱分離線程為守護線程(daemon threads),UNIX中守護線程是指,沒有任何顯式的用戶接口,并在后臺運行的線程。這種線程的特點就是長時間運行;線程的生命周期可能會從某一個應(yīng)用起始到結(jié)束,可能會在后臺監(jiān)視文件系統(tǒng),還有可能對緩存進行清理,亦或?qū)?shù)據(jù)結(jié)構(gòu)進行優(yōu)化。另一方面,分離線程的另一方面只能確定線程什么時候結(jié)束,發(fā)后即忘(fire and forget)的任務(wù)就使用到線程的這種方式。
如2.1.2節(jié)所示,調(diào)用std::thread
成員函數(shù)detach()來分離一個線程。之后,相應(yīng)的std::thread
對象就與實際執(zhí)行的線程無關(guān)了,并且這個線程也無法加入:
std::thread t(do_background_work);
t.detach();
assert(!t.joinable());
為了從std::thread
對象中分離線程(前提是有可進行分離的線程),不能對沒有執(zhí)行線程的std::thread
對象使用detach(),也是join()的使用條件,并且要用同樣的方式進行檢查——當(dāng)std::thread
對象使用t.joinable()返回的是true,就可以使用t.detach()。
試想如何能讓一個文字處理應(yīng)用同時編輯多個文檔。無論是用戶界面,還是在內(nèi)部應(yīng)用內(nèi)部進行,都有很多的解決方法。雖然,這些窗口看起來是完全獨立的,每個窗口都有自己獨立的菜單選項,但他們卻運行在同一個應(yīng)用實例中。一種內(nèi)部處理方式是,讓每個文檔處理窗口擁有自己的線程;每個線程運行同樣的的代碼,并隔離不同窗口處理的數(shù)據(jù)。如此這般,打開一個文檔就要啟動一個新線程。因為是對獨立的文檔進行操作,所以沒有必要等待其他線程完成。因此,這里就可以讓文檔處理窗口運行在分離的線程上。
下面代碼簡要的展示了這種方法:
清單2.4 使用分離線程去處理其他文檔
void edit_document(std::string const& filename)
{
open_document_and_display_gui(filename);
while(!done_editing())
{
user_command cmd=get_user_input();
if(cmd.type==open_new_document)
{
std::string const new_name=get_filename_from_user();
std::thread t(edit_document,new_name); // 1
t.detach(); // 2
}
else
{
process_user_input(cmd);
}
}
}
如果用戶選擇打開一個新文檔,需要啟動一個新線程去打開新文檔①,并分離線程②。與當(dāng)前線程做出的操作一樣,新線程只不過是打開另一個文件而已。所以,edit_document函數(shù)可以復(fù)用,通過傳參的形式打開新的文件。
這個例子也展示了傳參啟動線程的方法:不僅可以向std::thread
構(gòu)造函數(shù)①傳遞函數(shù)名,還可以傳遞函數(shù)所需的參數(shù)(實參)。C++線程庫的方式也不是很復(fù)雜。當(dāng)然,也有其他方法完成這項功能,比如:使用一個帶有數(shù)據(jù)成員的成員函數(shù),代替一個需要傳參的普通函數(shù)。