這章是C++并發的第一章內容,主要敘述執行緒管理、向執行緒傳遞引數、轉移執行緒、決定執行緒的數量、表示執行緒等,
-
執行緒管理的基礎
每個程式至少有一個執行緒:執行main()函式的執行緒,其余執行緒有其各自的入口函式,執行緒與原始執行緒(以main()為入口函式的執行緒)同時運行,如同main()函式執行完會退出一樣,當執行緒執行完入口函式后,執行緒也會退出,在為一個執行緒創建了一個std::thread物件后,需要等待這個執行緒結束;不過,執行緒需要先進行啟動,
執行緒啟動
我們可以構造一個std::thread物件,添加頭檔案<thread>
#include <thread>
void do_some_work(); std::thread my_thread(do_some_work);
std::thread可以用可呼叫型別構造,將帶有函式呼叫符型別的實體傳入std::thread類中,替換默認的建構式:
class background_task
{
public:
void operator()() const
{
do_something();
do_something_else();
}
};
background_task f;
std::thread my_thread(f);
代碼中,提供的函式物件會復制到新執行緒的存盤空間當中,函式物件的執行和呼叫都在執行緒的記憶體空間中進行,函式物件的副本應與原始函式物件保持一致,否則得到的結果會與我們的期望不同,
如果你傳遞了一個臨時變數,而不是一個命名的變數;C++編譯器會將其決議為函式宣告,而不是型別物件的定義,例如:
std::thread my_thread(background_task());
這里相當與宣告了一個名為my_thread的函式,這個函式帶有一個引數(函式指標指向沒有引數并回傳background_task物件的函式),回傳一個std::thread物件的函式,而非啟動了一個執行緒,
我們可以使用括號來避免這個問題:
std::thread my_thread((background_task())); // 1
std::thread my_thread{background_task()}; // 2
或使用lambda運算式:
std::thread my_thread([]{
do_something();
do_something_else();
});
啟動了執行緒,你需要明確是要等待執行緒結束(join),還是讓其自主運行(detach):
如果不等待執行緒,就必須保證執行緒結束之前,可訪問的資料得有效性,
這種情況很可能發生在執行緒還沒結束,函式已經退出的時候,這時執行緒函式還持有函式區域變數的指標或參考,下面的清單中就展示了這樣的一種情況,
例1:
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 不等待執行緒結束
}
這個例子中,已經決定不等待執行緒結束(使用了detach() ② ),所以當oops()函式執行完成時③,新執行緒中的函式可能還在運行,如果執行緒還在運行,它就會去呼叫do_something(i)函式①,這時就會訪問已經銷毀的變數,如同一個單執行緒程式——允許在函式完成后繼續持有區域變數的指標或參考;
運行順序如下表:
| 主執行緒 | 新執行緒 |
|---|---|
| 使用some_local_state構造my_func | |
| 開啟新執行緒my_thread | |
| 啟動 | |
| 呼叫func::operator() | |
| 將my_thread分離 | 執行func::operator();可能會在do_something中呼叫some_local_state的參考 |
| 銷毀some_local_state | 持續運行 |
| 退出oops函式 | 持續執行func::operator();可能會在do_something中呼叫some_local_state的參考 —> 導致未定義行為 |
處理這種情況的常規方法:使執行緒函式的功能齊全,將資料復制到執行緒中,而非復制到共享資料中,如果使用一個可呼叫的物件作為執行緒函式,這個物件就會復制到執行緒中,而后原始物件就會立即銷毀,但對于物件中包含的指標和參考還需謹慎,
執行緒等待:
如果需要等待執行緒,相關的std::thread實體需要使用join(),將my_thread.detach()替換為my_thread.join(),就可以確保區域變數在執行緒完成后,才被銷毀,在這種情況下,因為原始執行緒在其生命周期中并沒有做什么事,使得用一個獨立的執行緒去執行函式變得收益甚微,但在實際編程中,原始執行緒要么有自己的作業要做;要么會啟動多個子執行緒來做一些有用的作業,并等待這些執行緒結束,
join()是簡單粗暴的等待執行緒完成或不等待,當你需要對等待中的執行緒有更靈活的控制時,比如,看一下某個執行緒是否結束,或者只等待一段時間(超過時間就判定為超時),想要做到這些,你需要使用其他機制來完成,比如條件變數和期待(futures),呼叫join()的行為,還清理了執行緒相關的存盤部分,這樣std::thread物件將不再與已經完成的執行緒有任何關聯,這意味著,只能對一個執行緒使用一次join();一旦已經使用過join(),std::thread物件就不能再次加入了,當對其使用joinable()時,將回傳false,
注意:
如前所述,需要對一個還未銷毀的std::thread物件使用join()或detach(),如果想要分離一個執行緒,可以在執行緒啟動后,直接使用detach()進行分離,如果打算等待對應執行緒,則需要細心挑選呼叫join()的位置,當在執行緒運行之后產生例外,在join()呼叫之前拋出,就意味著這次呼叫會被跳過,
避免應用被拋出的例外所終止,就需要作出一個決定,通常,當傾向于在無例外的情況下使用join()時,需要在例外處理程序中呼叫join(),從而避免生命周期的問題,下面的程式清單是一個例子,
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
}
代碼使用了try/catch塊確保訪問本地狀態的執行緒退出后,函式才結束,當函式正常退出時,會執行到②處;當函式執行程序中拋出例外,程式會執行到①處,try/catch塊能輕易的捕獲輕量級錯誤,所以這種情況,并非放之四海而皆準,如需確保執行緒在函式之前結束——查看是否因為執行緒函式使用了區域變數的參考,以及其他原因——而后再確定一下程式可能會退出的途徑,無論正常與否,可以提供一個簡潔的機制,來做解決這個問題,
一種方式是使用“資源獲取即初始化方式”(RAII,Resource Acquisition Is Initialization),并且提供一個類,在解構式中使用join(),如同下面清單中的代碼,看它如何簡化f()函式,
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;
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
執行緒執行到④處時,區域物件就要被逆序銷毀了,因此,thread_guard物件g是第一個被銷毀的,這時執行緒在解構式中被加入②到原始執行緒中,即使do_something_in_current_thread拋出一個例外,這個銷毀依舊會發生,
在thread_guard的解構式的測驗中,首先判斷執行緒是否已加入①,如果沒有會呼叫join()②進行加入,這很重要,因為join()只能對給定的物件呼叫一次,所以對給已加入的執行緒再次進行加入操作時,將會導致錯誤,
拷貝建構式和拷貝賦值操作被標記為=delete③,是為了不讓編譯器自動生成它們,直接對一個物件進行拷貝或賦值是危險的,因為這可能會弄丟已經加入的執行緒,通過洗掉宣告,任何嘗試給thread_guard物件賦值的操作都會引發一個編譯錯誤,想要了解洗掉函式的更多知識,請參閱附錄A的A.2節,
如果不想等待執行緒結束,可以分離_(_detaching)執行緒,從而避免例外安全(exception-safety)問題,不過,這就打破了執行緒與std::thread物件的聯系,即使執行緒仍然在后臺運行著,分離操作也能確保std::terminate()在std::thread物件銷毀才被呼叫,
后臺運行執行緒:
使用detach()會讓執行緒在后臺運行,這就意味著主執行緒不能與之產生直接互動,也就是說,不會等待這個執行緒結束;如果執行緒分離,那么就不可能有std::thread物件能參考它,分離執行緒的確在后臺運行,所以分離執行緒不能被加入,不過C++運行庫保證,當執行緒退出時,相關資源的能夠正確回收,后臺執行緒的歸屬和控制C++運行庫都會處理,
通常稱分離執行緒為守護執行緒(daemon threads),UNIX中守護執行緒是指,沒有任何顯式的用戶介面,并在后臺運行的執行緒,這種執行緒的特點就是長時間運行;執行緒的生命周期可能會從某一個應用起始到結束,可能會在后臺監視檔案系統,還有可能對快取進行清理,亦或對資料結構進行優化,另一方面,分離執行緒的另一方面只能確定執行緒什么時候結束,發后即忘(fire and forget)的任務就使用到執行緒的這種方式,
呼叫std::thread成員函式detach()來分離一個執行緒,之后,相應的std::thread物件就與實際執行的執行緒無關了,并且這個執行緒也無法加入:
std::thread t(do_background_work); t.detach(); assert(!t.joinable());
為了從std::thread物件中分離執行緒(前提是有可進行分離的執行緒),不能對沒有執行執行緒的std::thread物件使用detach(),也是join()的使用條件,并且要用同樣的方式進行檢查——當std::thread物件使用t.joinable()回傳的是true,就可以使用t.detach(),
試想如何能讓一個文字處理應用同時編輯多個檔案,無論是用戶界面,還是在內部應用內部進行,都有很多的解決方法,雖然,這些視窗看起來是完全獨立的,每個視窗都有自己獨立的選單選項,但他們卻運行在同一個應用實體中,一種內部處理方式是,讓每個檔案處理視窗擁有自己的執行緒;每個執行緒運行同樣的的代碼,并隔離不同視窗處理的資料,如此這般,打開一個檔案就要啟動一個新執行緒,因為是對獨立的檔案進行操作,所以沒有必要等待其他執行緒完成,因此,這里就可以讓檔案處理視窗運行在分離的執行緒上,
下面代碼簡要的展示了這種方法:
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);
}
}
}
如果用戶選擇打開一個新檔案,需要啟動一個新執行緒去打開新檔案①,并分離執行緒②,與當前執行緒做出的操作一樣,新執行緒只不過是打開另一個檔案而已,所以,edit_document函式可以復用,通過傳參的形式打開新的檔案,
2. 向執行緒函式傳遞引數
向std::thread建構式中的可呼叫物件,或函式傳遞一個引數很簡單,需要注意的是,默認引數要拷貝到執行緒獨立記憶體中,即使引數是參考的形式,也可以在新執行緒中進行訪問,再來看一個例子:
void f(int i, std::string const& s); std::thread t(f, 3, "hello");
碼創建了一個呼叫f(3, “hello”)的執行緒,注意,函式f需要一個std::string物件作為第二個引數,但這里使用的是字串的字面值,也就是char const *型別,之后,在執行緒的背景關系中完成字面值向std::string物件的轉化,需要特別要注意,當指向動態變數的指標作為引數傳遞給執行緒的情況,代碼如下:
void f(int i,std::string const& s);
void oops(int some_param)
{
char buffer[1024]; // 1
sprintf(buffer, "%i",some_param);
std::thread t(f,3,buffer); // 2
t.detach();
}
這種情況下,buffer①是一個指標變數,指向本地變數,然后本地變數通過buffer傳遞到新執行緒中②,并且,函式有很有可能會在字面值轉化成std::string物件之前崩潰(oops),從而導致一些未定義的行為,并且想要依賴隱式轉換將字面值轉換為函式期待的std::string物件,但因std::thread的建構式會復制提供的變數,就只復制了沒有轉換成期望型別的字串字面值,
解決方案就是在傳遞到std::thread建構式之前就將字面值轉化為std::string物件:
void f(int i,std::string const& s);
void not_oops(int some_param)
{
char buffer[1024];
sprintf(buffer,"%i",some_param);
std::thread t(f,3,std::string(buffer)); // 使用std::string,避免懸垂指標
t.detach();
}
還可能遇到相反的情況:期望傳遞一個非常量參考(但這不會被編譯),但整個物件被復制了,你可能會嘗試使用執行緒更新一個參考傳遞的資料結構,比如:
void update_data_for_widget(widget_id w,widget_data& data); // 1
void oops_again(widget_id w)
{
widget_data data;
std::thread t(update_data_for_widget,w,data); // 2
display_status();
t.join();
process_widget_data(data);
}
雖然update_data_for_widget①的第二個引數期待傳入一個參考,但是std::thread的建構式②并不知曉;建構式無視函式期待的引數型別,并盲目的拷貝已提供的變數,不過,在代碼會將引數以右值的方式進行拷貝傳遞,這是為了照顧到那些只能進行移動的型別,而后會以右值為引數呼叫update_data_for_widget,因為函式期望的是一個非常量參考作為引數,而非一個右值作為引數,所以會在編譯時出錯,對于熟悉std::bind的開發者來說,問題的解決辦法是顯而易見的:可以使用std::ref將引數轉換成參考的形式,從而可將執行緒的呼叫改為以下形式:
std::thread t(update_data_for_widget,w,std::ref(data));
在這之后,update_data_for_widget就會接收到一個data變數的參考,而非一個data變數拷貝的參考,這樣代碼就能順利的通過編譯,
如果你熟悉std::bind,就應該不會對以上述傳參的形式感到奇怪,因為std::thread建構式和std::bind的操作都在標準庫中定義好了,可以傳遞一個成員函數指標作為執行緒函式,并提供一個合適的物件指標作為第一個引數:
class X
{
public:
void do_lengthy_work();
};
X my_x;
std::thread t(&X::do_lengthy_work,&my_x); // 1
這段代碼中,新執行緒將my_x.do_lengthy_work()作為執行緒函式;my_x的地址①作為指標物件提供給函式,也可以為成員函式提供引數:std::thread建構式的第三個引數就是成員函式的第一個引數,以此類推:
class X
{
public:
void do_lengthy_work(int);
};
X my_x;
int num(0);
std::thread t(&X::do_lengthy_work, &my_x, num);
提供的引數可以移動,但不能拷貝,”移動”是指:原始物件中的資料轉移給另一物件,而轉移的這些資料就不再在原始物件中保存了,std::unique_ptr就是這樣一種型別,這種型別為動態分配的物件提供記憶體自動管理機制, 同一時間內,只允許一個std::unique_ptr實作指向一個給定物件,并且當這個實作銷毀時,指向的物件也將被洗掉,移動建構式(move constructor)和移動賦值運算子(move assignment operator)允許一個物件在多個std::unique_ptr實作中傳遞, 使用”移動”轉移原物件后,就會留下一個空指標(NULL),移動操作可以將物件轉換成可接受的型別,例如:函式引數或函式回傳的型別,當原物件是一個臨時變數時,自動進行移動操作,但當原物件是一個命名變數,那么轉移的時候就需要使用std::move()進行顯示移動,下面的代碼展示了std::move的用法,展示了std::move是如何轉移一個動態物件到一個執行緒中去的:
void process_big_object(std::unique_ptr<big_object>); std::unique_ptr<big_object> p(new big_object); p->prepare_data(42); std::thread t(process_big_object,std::move(p));
std::thread的建構式中指定std::move(p),big_object物件的所有權就被首先轉移到新創建執行緒的的內部存盤中,之后傳遞給process_big_object函式,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/135368.html
標籤:C++
上一篇:v
下一篇:P1002 過河卒
