C++併發程式設計1
C++11中引入了多執行緒程式設計,一般教科書中都沒有涉及到這個概念,但是在工作中多執行緒卻又是必不可少的。本文會從最簡單的hello world入手,細述如何建立管理執行緒。
Hello World
經典的Hello World式開端。
#include <iostream>
#include <thread>
void hello()
{
std::cout << "Hello world" << std::endl;
}
int main()
{
std::thread t(hello);
t.join(); // 沒有這句話,會Debug Error的
return 0;
}
這段程式碼很簡單,如果用過boost多執行緒程式設計,那麼應該對這個瞭如指掌了。首先包含執行緒庫標頭檔案<thread>
,然後定義一個執行緒物件t,執行緒物件負責管理以hello()函式作為初始函式的執行緒,join()等待執行緒函式執行完成——這兒是阻塞的。
建立執行緒
上文中的經典hello world例子使用了最基本的執行緒建立方法,也是我們最常用的方法。std::thread物件的構造引數需要為Callable Object,可以是函式、函式物件、類的成員函式或者是Lambda表示式。接下來我們會給出這四種建立執行緒的方法。
以函式作為引數
上文中的Hello C++ Concurrency程式,就是最好的以函式為引數構造std::thread的例子,這裡不再贅述。
以函式物件作為引數
函式物件利用了C++類的呼叫過載運算子,實現了該過載運算子的類物件可以當成函式一樣進行呼叫。如下例:
#include <iostream>
#include <thread>
class hello
{
public:
hello(){ }
void operator()()const
{
std::cout << "Hello world" << std ::endl;
}
};
int main()
{
hello h;
std::thread t1(h);
t1.join();
return 0;
}
這裡需要注意一點:如果需要直接傳遞臨時的函式物件,C++編譯器會將std::thread物件構造解析為函式宣告:
std::thread t2(hello()); // error, compile as std::thread t2(hello(*)());
std::thread t3((hello())); // ok
std::thread t4{ hello() }; // ok
t2.join(); // compile error: expression must have class type
t3.join(); // ok
t4.join(); // ok
以類的成員函式作為引數
為了作為std::thread的構造引數,類的成員函式名必須唯一,在下例中,如果world1()和world2()函式名都是world,則編譯出錯,這是因為名字解析發生在引數匹配之前。
#include <iostream>
#include <thread>
#include <string>
class hello
{
public:
hello(){ }
void world1()
{
std::cout << "Hello world" << std::endl;
}
void world2(std::string text)
{
std::cout << "Hello world, " << text << std::endl;
}
};
int main()
{
hello h;
std::thread t1(&hello::world1, &h);
std::thread t2(&hello::world2, &h, "lee");
t1.join();
t2.join();
return 0;
}
以lambda物件作為引數
#include <iostream>
#include <thread>
#include <string>
int main()
{
std::thread t([](std::string text){
std::cout << "hello world, " << text << std::endl;
}, "lee");
t.join();
return 0;
}
建立執行緒物件時需要切記,使用一個能訪問區域性變數的函式去建立執行緒是一個糟糕的注意。
等待執行緒
join()等待執行緒完成,只能對一個執行緒物件呼叫一次join(),因為呼叫join()的行為,負責清理執行緒相關內容,如果再次呼叫,會出現Runtime
Error
。
std::thread t([](){
std::cout << "hello world" << std::endl;
});
t.join(); // ok
t.join(); // runtime error
if(t.joinable())
{
t.join(); // ok
}
對join()的呼叫,需要選擇合適的呼叫時機。如果執行緒執行之後父執行緒產生異常,在join()呼叫之前丟擲,就意味著這次呼叫會被跳過。解決辦法是,在無異常的情況下使用join()——在異常處理過程中呼叫join()。
#include <iostream>
#include <thread>
#include <string>
int main()
{
std::thread t([](std::string text){
std::cout << "hello world, " << text << std::endl;
}, "lee");
try
{
throw std::exception("test");
}
catch (std::exception e)
{
std::cout << e.what() << std::endl;
t.join();
}
if (t.joinable())
{
t.join();
}
return 0;
}
上面並非解決這個問題的根本方法,如果其他問題導致程式提前退出,上面方案無解,最好的方法是所謂的RAII。
#include <iostream>
#include <thread>
#include <string>
class thread_guard
{
public:
explicit thread_guard(std::thread &_t)
: t(std::move(_t))
{
if(!t.joinable())
throw std::logic_error("No Thread");
}
~thread_guard()
{
if (t.joinable())
{
t.join();
}
}
thread_guard(thread_guard const&) = delete;
thread_guard& operator=(thread_guard const &) = delete;
private:
std::thread t;
};
void func()
{
thread_guard guard(std::thread([](std::string text){
std::cout << "hello world, " << text << std::endl;
}, "lee"));
try
{
throw std::exception("test");
}
catch (...)
{
throw;
}
}
int main()
{
try
{
func();
}
catch (std::exception e)
{
std::cout << e.what() << std::endl;
}
return 0;
}
分離執行緒
detach()將子執行緒和父執行緒分離。分離執行緒後,可以避免異常安全問題,即使執行緒仍在後臺執行,分離操作也能確保std::terminate在std::thread物件銷燬時被呼叫。
通常稱分離執行緒為守護執行緒(deamon threads),這種執行緒的特點就是長時間執行;執行緒的生命週期可能會從某一個應用起始到結束,可能會在後臺監視檔案系統,還有可能對快取進行清理,亦或對資料結構進行優化。
#include <iostream>
#include <thread>
#include <string>
#include <assert.h>
int main()
{
std::thread t([](std::string text){
std::cout << "hello world, " << text << std::endl;
}, "lee");
if (t.joinable())
{
t.detach();
}
assert(!t.joinable());
return 0;
}
上面的程式碼中使用到了joinable()
函式,不能對沒有執行執行緒的std::thread物件使用detach(),必須要使用joinable()函式來判斷是否可以加入或分離。
執行緒傳參
正常的執行緒傳參是很簡單的,但是需要記住下面一點:預設情況下,即使我們執行緒函式的引數是引用型別,引數會先被拷貝到執行緒空間,然後被執行緒執行體訪問。上面的執行緒空間為執行緒能夠訪問的內部記憶體。我們來看下面的例子:
void f(int i,std::string const& s);
std::thread t(f,3,”hello”);
即使f的第二個引數是引用型別,字串字面值"hello"還是被拷貝到執行緒t空間內,然後被轉換為std::string型別。在上面這種情況下不會出錯,但是在下面這種引數為指向自動變數的指標的情況下就很容易出錯。
void f(int i,std::string const& s);
void oops(int some_param)
{
char buffer[1024];
sprintf(buffer, "%i",some_param);
std::thread t(f,3,buffer);
t.detach();
}
在這種情況下,指標變數buffer將會被拷貝到執行緒t空間內,這個時候很可能函式oops結束了,buffer還沒有被轉換為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));
t.detach();
}
由於上面所說,程序傳參時,引數都會被進行一次拷貝,所以即使我們將程序函式引數設為引用,也只是對這份拷貝的引用。我們對引數的操作並不會改變其傳參之前的值。看下面例子:
void update_data_for_widget(widget_id w,widget_data& data);
void oops_again(widget_id w)
{
widget_data data;
std::thread t(update_data_for_widget,w,data);
display_status();
t.join();
process_widget_data(data);
}
執行緒t執行完成之後,data的值並不會有所改變,process_widget_data(data)函式處理的就是一開始的值。我們需要顯示的宣告引用傳參,使用std::ref包裹需要被引用傳遞的引數即可解決上面問題:
void update_data_for_widget(widget_id w,widget_data& data);
void oops_again(widget_id w)
{
widget_data data;
std::thread t(update_data_for_widget,w,std::ref(data));
display_status();
t.join();
process_widget_data(data);
}
對於可以移動不可拷貝的引數,譬如std::unqiue_ptr物件,如果源物件是臨時的,移動操作是自動執行的;如果源物件是命名變數,必須顯式呼叫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::thread物件之間轉移執行緒所有權使用sd::move函式。
void some_function();
void some_other_function();
std::thread t1(some_function); // 1
std::thread t2=std::move(t1); // 2
t1=std::thread(some_other_function); // 3 臨時物件會隱式呼叫std::move轉移執行緒所有權
std::thread t3; // 4
t3=std::move(t2); // 5
t1=std::move(t3); // 6 賦值操作將使程式崩潰
t1.detach();
t1=std::move(t3); // 7 ok
這裡需要注意的是臨時物件會隱式呼叫std::move轉移執行緒所有權,所以t1=std::thread(some_other_function);不需要顯示呼叫std::move。如果需要析構thread物件,必須等待join()返回或者是detach(),同樣,如果需要轉移執行緒所有權,必須要等待接受執行緒物件的執行函式完成,不能通過賦一個新值給std::thread物件的方式來"丟棄"一個執行緒。第6點中,t1仍然和some_other_function聯絡再一次,所以不能直接轉交t3的所有權給t1。
std::thread支援移動,就意味著執行緒的所有權可以在函式外進行轉移。
std::thread f()
{
void some_function();
return std::thread(some_function);
}
std::thread g()
{
void some_other_function(int);
std::thread t(some_other_function,42);
return t;
}
當所有權可以在函式內部傳遞,就允許std::thread例項可作為引數進行傳遞。
void f(std::thread t);
void g()
{
void some_function();
f(std::thread(some_function));
std::thread t(some_function);
f(std::move(t));
}
利用這個特性,我們可以實現執行緒物件的RAII封裝。
class thread_guard
{
public:
explicit thread_guard(std::thread &_t)
: t(std::move(_t))
{
if (!t.joinable())
throw std::logic_error("No Thread");
}
~thread_guard()
{
if (t.joinable())
{
t.join();
}
}
thread_guard(thread_guard const&) = delete;
thread_guard& operator=(thread_guard const &) = delete;
private:
std::thread t;
};
struct func;
void f() {
int some_local_state;
scoped_thread t(std::thread(func(some_local_state)));
do_something_in_current_thread();
}
利用執行緒可以轉移的特性我們可以用容器來集中管理執行緒,看下面程式碼:
void do_work(unsigned id);
void f() {
std::vector<std::thread> threads;
for(unsigned i=0;i<20;++i)
{
threads.push_back(std::thread(do_work,i));
}
std::for_each(threads.begin(),threads.end(),
std::mem_fn(&std::thread::join));
}
執行緒相關
執行緒數量
std::thread::hardware_concurrency()函式返回一個程式中能夠同時併發的執行緒數量,在多核系統中,其一般是核心數量。但是這個函式僅僅是一個提示,當系統資訊無法獲取時,函式會返回0。看下面並行處理的例子:
識別執行緒
執行緒標識型別是std::thread::id,可以通過兩種方式進行檢索。
- 通過呼叫std::thread物件的成員函式get_id()來直接獲取。
- 當前執行緒中呼叫std::this_thread::get_id()也可以獲得執行緒標識。
上面的方案和執行緒sleep很相似,使用上面一樣的格式,get_id()函式替換成sleep()函式即可。
std::thread::id物件可以自由的拷貝和對比:
- 如果兩個物件的std::thread::id相等,那它們就是同一個執行緒,或者都“沒有執行緒”。
- 如果不等,那麼就代表了兩個不同執行緒,或者一個有執行緒,另一沒有。
std::thread::id例項常用作檢測特定執行緒是否需要進行一些操作,這常常用在某些執行緒需要執行特殊操作的場景,我們必須先要找出這些執行緒。
關注微信公眾號FreeHacker