寫在前面:說到多線程編程,那么就不得不提並行和並發,多線程是實現並發(並行)的一種手段。並行是指兩個或多個獨立的操作同時進行。注意這里是同時進行,區別於並發,在一個時間段內執行多個操作。在單核時代,多個線程是並發的,在一個時間段內輪流執行;在多核時代,多個線程可以實現真正的並行,在多核上真正獨立的並行執行。例如現在常見的4核4線程可以並行4個線程;4核8線程則使用了超線程技術,把一個物理核模擬為2個邏輯核心,可以並行8個線程。
並發編程的方法:多進程和多線程。
多進程並發(更安全)
使用多進程並發是將一個應用程序划分為多個獨立的進程(每個進程只有一個線程),這些獨立的進程間可以互相通信,共同完成任務。由於操作系統對進程提供了大量的保護機制,以避免一個進程修改了另一個進程的數據,使用多進程比多線程更容易寫出安全的代碼。但這也造就了多進程並發的兩個缺點:
- 在進程間的通信,無論是使用信號、套接字,還是文件、管道等方式,其使用要么比較復雜,要么就是速度較慢或者兩者兼而有之。
- 運行多個進程的開銷很大,操作系統要分配很多的資源來對這些進程進行管理。
由於多個進程並發完成同一個任務時,不可避免的是:操作同一個數據和進程間的相互通信,上述的兩個缺點也就決定了多進程的並發不是一個好的選擇。
多線程並發
多線程並發指的是在同一個進程中執行多個線程。有操作系統相關知識的應該知道,線程是輕量級的進程,每個線程可以獨立地運行不同的指令序列,但是線程不獨立的擁有資源,依賴於創建它的進程而存在。也就是說,同一進程中的多個線程共享相同的地址空間,可以訪問進程中的大部分數據,指針和引用可以在線程間進行傳遞。這樣,同一進程內的多個線程能夠很方便的進行數據共享以及通信,也就比進程更適用於並發操作。由於缺少操作系統提供的保護機制,在多線程共享數據及通信時,就需要程序員做更多的工作以保證對共享數據段的操作是以預想的操作順序進行的,並且要極力地避免死鎖(deadlock)。
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 (int 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
對象來管理該線程。
func();
std::thread(func);
這里創建傳入的函數,實際上其構造函數需要的是可調用(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 (int 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(); }
向線程傳遞參數
向線程調用的函數傳遞參數也是很簡單的,只需要在構造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*
類型傳入線程空間后,在。
轉移線程的所有權
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()
獲取