C++11多執行緒----執行緒管理
說到多執行緒程式設計,那麼就不得不提並行和併發,多執行緒是實現併發(並行)的一種手段。並行是指兩個或多個獨立的操作同時進行。注意這裡是同時進行,區別於併發,在一個時間段內執行多個操作。在單核時代,多個執行緒是併發的,在一個時間段內輪流執行;在多核時代,多個執行緒可以實現真正的並行,在多核上真正獨立的並行執行。例如現在常見的4核4線程可以並行4個執行緒;4核8執行緒則使用了超執行緒技術,把一個物理核模擬為2個邏輯核心,可以並行8個執行緒。
併發程式設計的方法
通常,要實現併發有兩種方法:多程序和多執行緒。
多程序併發
使用多程序併發是將一個應用程式劃分為多個獨立的程序(每個程序只有一個執行緒),這些獨立的程序間可以互相通訊,共同完成任務。由於作業系統對程序提供了大量的保護機制,以避免一個程序修改了另一個程序的資料,使用多程序比多執行緒更容易寫出安全的程式碼。但這也造就了多程序併發的兩個缺點:
- 在程序件的通訊,無論是使用訊號、套接字,還是檔案、管道等方式,其使用要麼比較複雜,要麼就是速度較慢或者兩者兼而有之。
- 執行多個執行緒的開銷很大,作業系統要分配很多的資源來對這些程序進行管理。
由於多個程序併發完成同一個任務時,不可避免的是:操作同一個資料和程序間的相互通訊,上述的兩個缺點也就決定了多程序的併發不是一個好的選擇。
多執行緒併發
多執行緒併發指的是在同一個程序中執行多個執行緒。有作業系統相關知識的應該知道,執行緒是輕量級的程序,每個執行緒可以獨立的執行不同的指令序列,但是執行緒不獨立的擁有資源,依賴於建立它的程序而存在。也就是說,同一程序中的多個執行緒共享相同的地址空間,可以訪問程序中的大部分資料,指標和引用可以線上程間進行傳遞
C++ 11的多執行緒初體驗
C++11的標準庫中提供了多執行緒庫,使用時需要
#include <thread>
標頭檔案,該標頭檔案主要包含了對執行緒的管理類std::thread
以及其他管理執行緒相關的類。下面是使用C++多執行緒庫的一個簡單示例:
#include <iostream>
#include <thread>
using namespace std;
void output(int i)
{
cout << i << endl;
}
int main()
{
for (uint8_t i = 0; i < 4; i++)
{
thread t(output, i);
t.detach();
}
getchar();
return 0;
}
在一個for迴圈內,建立4個執行緒分別輸出數字0、1、2、3,並且在每個數字的末尾輸出換行符。語句thread t(output, i)
建立一個執行緒t,該執行緒執行output
,第二個引數i是傳遞給output
的引數。t在建立完成後自動啟動,t.detach
表示該執行緒在後臺允許,無需等待該執行緒完成,繼續執行後面的語句。這段程式碼的功能是很簡單的,如果是順序執行的話,其結果很容易預測得到
0 \n 1 \n 2 \n 3 \n
但是在並行多執行緒下,其執行的結果就多種多樣了,下圖是程式碼一次執行的結果:
可以看出,首先輸出了01,並沒有輸出換行符;緊接著卻連續輸出了2個換行符。不是說好的並行麼,同時執行,怎麼還有先後的順序?這就涉及到多執行緒程式設計最核心的問題了資源競爭。CPU有4核,可以同時執行4個執行緒這是沒有問題了,但是控制檯卻只有一個,同時只能有一個執行緒擁有這個唯一的控制檯,將數字輸出。將上面程式碼建立的四個執行緒進行編號:t0,t1,t2,t3,分別輸出的數字:0,1,2,3。參照上圖的執行結果,控制檯的擁有權的轉移如下:
- t0擁有控制檯,輸出了數字0,但是其沒有來的及輸出換行符,控制的擁有權卻轉移到了t1;(0)
- t1完成自己的輸出,t1執行緒完成 (1\n)
- 控制檯擁有權轉移給t0,輸出換行符 (\n)
- t2擁有控制檯,完成輸出 (2\n)
- t3擁有控制檯,完成輸出 (3\n)
由於控制檯是系統資源,這裡控制檯擁有權的管理是作業系統完成的。但是,假如是多個執行緒共享程序空間的資料,這就需要自己寫程式碼控制,每個執行緒何時能夠擁有共享資料進行操作。共享資料的管理以及執行緒間的通訊,是多執行緒程式設計的兩大核心。
執行緒管理
每個應用程式至少有一個程序,而每個程序至少有一個主執行緒,除了主執行緒外,在一個程序中還可以建立多個執行緒。每個執行緒都需要一個入口函式,入口函式返回退出,該執行緒也會退出,主執行緒就是以main
函式作為入口函式的執行緒。在C++ 11的執行緒庫中,將執行緒的管理在了類std::thread
中,使用std::thread
可以建立、啟動一個執行緒,並可以將執行緒掛起、結束等操作。
啟動一個執行緒
C++ 11的執行緒庫啟動一個執行緒是非常簡單的,只需要建立一個std::thread
物件,就會啟動一個執行緒,並使用該std::thread
物件來管理該執行緒。
do_task();
std::thread(do_task);
這裡建立std::thread
傳入的函式,實際上其建構函式需要的是可呼叫(callable)型別,只要是有函式呼叫型別的例項都是可以的。所有除了傳遞函式外,還可以使用:
- lambda表示式
使用lambda表示式啟動執行緒輸出數字
for (int i = 0; i < 4; i++)
{
thread t([i]{
cout << i << endl;
});
t.detach();
}
- 過載了()運算子的類的例項
使用過載了()運算子的類實現多執行緒數字輸出
class Task
{
public:
void operator()(int i)
{
cout << i << endl;
}
};
int main()
{
for (uint8_t i = 0; i < 4; i++)
{
Task task;
thread t(task, i);
t.detach();
}
}
把函式物件傳入std::thread
的建構函式時,要注意一個C++的語法解析錯誤(C++’s most vexing parse)。向std::thread
的建構函式中傳入的是一個臨時變數,而不是命名變數就會出現語法解析錯誤。如下程式碼:
std::thread t(Task());
這裡相當於聲明瞭一個函式t,其返回型別為thread
,而不是啟動了一個新的執行緒。可以使用新的初始化語法避免這種情況
std::thread t{Task()};
當執行緒啟動後,一定要在和執行緒相關聯的thread
銷燬前,確定以何種方式等待執行緒執行結束。C++11有兩種方式來等待執行緒結束
- detach方式,啟動的執行緒自主在後臺執行,當前的程式碼繼續往下執行,不等待新執行緒結束。前面程式碼所使用的就是這種方式。
- join方式,等待啟動的執行緒完成,才會繼續往下執行。假如前面的程式碼使用這種方式,其輸出就會0,1,2,3,因為每次都是前一個執行緒輸出完成了才會進行下一個迴圈,啟動下一個新執行緒。
無論在何種情形,一定要在thread銷燬前,呼叫t.join或者t.detach,來決定執行緒以何種方式執行。當使用join方式時,會阻塞當前程式碼,等待執行緒完成退出後,才會繼續向下執行;而使用detach方式則不會對當前程式碼造成影響,當前程式碼繼續向下執行,建立的新執行緒同時併發執行,這時候需要特別注意:建立的新執行緒對當前作用域的變數的使用,建立新執行緒的作用域結束後,有可能執行緒仍然在執行,這時區域性變數隨著作用域的完成都已銷燬,如果執行緒繼續使用區域性變數的引用或者指標,會出現意想不到的錯誤,並且這種錯誤很難排查。例如:
auto fn = [](int *a){
for (int i = 0; i < 10; i++)
cout << *a << endl;
};
[]{
int a = 100;
thread t(fn, &a);
t.detach();
}();
在lambda表示式中,使用fn啟動了一個新的執行緒,在裝個新的執行緒中使用了局部變數a的指標,並且將該執行緒的執行方式設定為detach。這樣,在lamb表示式執行結束後,變數a被銷燬,但是在後臺執行的執行緒仍然在使用已銷燬變數a的指標,其輸出結果如下:
只有第一個輸出是正確的值,後面輸出的值是a已被銷燬後輸出的結果。所以在以detach的方式執行執行緒時,要將執行緒訪問的區域性資料複製到執行緒的空間(使用值傳遞),一定要確保執行緒沒有使用區域性變數的引用或者指標,除非你能肯定該執行緒會在區域性作用域結束前執行結束。當然,使用join方式的話就不會出現這種問題,它會在作用域結束前完成退出。
異常情況下等待執行緒完成
當決定以detach方式讓執行緒在後臺執行時,可以在建立thread
的例項後立即呼叫detach
,這樣執行緒就會後thread的例項分離,即使出現了異常thread
的例項被銷燬,仍然能保證執行緒在後臺執行。但執行緒以join
方式執行時,需要在主執行緒的合適位置呼叫join
方法,如果呼叫join前出現了異常,thread
被銷燬,執行緒就會被異常所終結。為了避免異常將執行緒終結,或者由於某些原因,例如執行緒訪問了區域性變數,就要保證執行緒一定要在函式退出前完成,就要保證要在函式退出前呼叫join
void func() {
thread t([]{
cout << "hello C++ 11" << endl;
});
try
{
do_something_else();
}
catch (...)
{
t.join();
throw;
}
t.join();
}
上面程式碼能夠保證在正常或者異常的情況下,都會呼叫join
方法,這樣執行緒一定會在函式func
退出前完成。但是使用這種方法,不但程式碼冗長,而且會出現一些作用域的問題,並不是一個很好的解決方法。
一種比較好的方法是資源獲取即初始化(RAII,Resource Acquisition Is Initialization),該方法提供一個類,在解構函式中呼叫join
。
class thread_guard
{
thread &t;
public :
explicit thread_guard(thread& _t) :
t(_t){}
~thread_guard()
{
if (t.joinable())
t.join();
}
thread_guard(const thread_guard&) = delete;
thread_guard& operator=(const thread_guard&) = delete;
};
void func(){
thread t([]{
cout << "Hello thread" <<endl ;
});
thread_guard g(t);
}
無論是何種情況,當函式退出時,區域性變數g
呼叫其解構函式銷燬,從而能夠保證join
一定會被呼叫。
向執行緒傳遞引數
向執行緒呼叫的函式傳遞引數也是很簡單的,只需要在構造thread
的例項時,依次傳入即可。例如:
void func(int *a,int n){}
int buffer[10];
thread t(func,buffer,10);
t.join();
需要注意的是,預設的會將傳遞的引數以拷貝的方式複製到執行緒空間,即使引數的型別是引用。例如:
void func(int a,const string& str);
thread t(func,3,"hello");
func
的第二個引數是string &
,而傳入的是一個字串字面量。該字面量以const char*
型別傳入執行緒空間後,在執行緒的空間內轉換為string
。
如果線上程中使用引用來更新物件時,就需要注意了。預設的是將物件拷貝到執行緒空間,其引用的是拷貝的執行緒空間的物件,而不是初始希望改變的物件。如下:
class _tagNode
{
public:
int a;
int b;
};
void func(_tagNode &node)
{
node.a = 10;
node.b = 20;
}
void f()
{
_tagNode node;
thread t(func, node);
t.join();
cout << node.a << endl ;
cout << node.b << endl ;
}
線上程內,將物件的欄位a和b設定為新的值,但是線上程呼叫結束後,這兩個欄位的值並不會改變。這樣由於引用的實際上是區域性變數node的一個拷貝,而不是node
本身。在將物件傳入執行緒的時候,呼叫std::ref
,將node
的引用傳入執行緒,而不是一個拷貝。
thread t(func,std::ref(node));
也可以使用類的成員函式作為執行緒函式,示例如下
class _tagNode{
public:
void do_some_work(int a);
};
_tagNode node;
thread t(&_tagNode::do_some_work, &node,20);
上面建立的執行緒會呼叫node.do_some_work(20)
,第三個引數為成員函式的第一個引數,以此類推。
轉移執行緒的所有權
thread
是可移動的(movable)的,但不可複製(copyable)。可以通過move
來改變執行緒的所有權,靈活的決定執行緒在什麼時候join或者detach。
thread t1(f1);
thread t3(move(t1));
將執行緒從t1轉移給t3,這時候t1就不再擁有執行緒的所有權,呼叫t1.join
或t1.detach
會出現異常,要使用t3來管理執行緒。這也就意味著thread
可以作為函式的返回型別,或者作為引數傳遞給函式,能夠更為方便的管理執行緒。
執行緒的標識型別為std::thread::id
,有兩種方式獲得到執行緒的id。
- 通過
thread
的例項呼叫get_id()
直接獲取 - 在當前執行緒上呼叫
this_thread::get_id()
獲取
總結
本文主要介紹了C++11引入的標準多執行緒庫的一些基本操作。有以下內容:
- 執行緒的建立
- 執行緒的執行方式,join或者detach
- 向執行緒函式傳遞引數,需要注意的是執行緒預設是以拷貝的方式傳遞引數的,當期望傳入一個引用時,要使用std::ref進行轉換
- 執行緒是movable的,可以在函式內部或者外部進行傳遞
- 每個執行緒都一個標識,可以呼叫get_id獲取。