前言
由于C++98/03標準沒有提供線程庫,所以在C++11之前一般使用的是平臺特定的線程庫,或者干脆使用第三方庫。個人而言,以前用過Linux的Pthreads庫,也看過過Win API的線程相關函數,都是以一種C風格的方式來傳遞參數,即輸入和輸出類型均為void*,由于C中指針類型本質是一樣的,可以進行強制類型轉換,所以這種方式可以輸入和輸出任意數量的參數,只不過比較蹩腳,這里以實現一個分割字符串的線程函數為例。
C風格的方式(使用pthread)
// pthread_demo.cc
#include <iostream>
#include <string>
#include <pthread.h>
struct InputType {
std::string s;
size_t pos;
size_t len;
};
void* thread_substr(void* arg) {
InputType* p = (InputType*) arg; // 強制轉換后還原輸入參數
std::cout << p->s.substr(p->pos, p->len) << std::endl;
return NULL;
}
int main() {
// 1. 將輸入參數封裝成單個對象
InputType in;
in.s = "Hello World!";
in.pos = 6;
in.len = in.s.size() - in.pos;
// 2. 創(chuàng)建pthread線程
pthread_t tid;
pthread_create(&tid, NULL, thread_substr, &in);
// 3. 等待pthread線程運行結束
void* res;
pthread_join(tid, &res);
return 0;
}
編譯時需要加上-pthread選項
xyz@ubuntu:~$ g++ pthread_substr.cc -pthread
xyz@ubuntu:~$ ./a.out
World!
首先需要說明的一點是,pthread_xxx()函數成功則返回0,否則返回錯誤碼,對于這種C風格API,實際編程中檢查返回值是必要的,否則出錯之后很難定位。
可以發(fā)現這種T*->void*的方式非常麻煩,而且平臺相關的API往往還需要詳盡的其他參數,比如pthread_create的第2個參數代表線程屬性,比如優(yōu)先級/調度策略/棧地址大小,參考我之前寫的博客Linux的POSIX線程屬性,這個參數的配置復雜程度不亞于線程操作。而Win API則是提供了好幾個參數,用平臺特定的宏OR運算來一個個指定,參見CreateThread
HANDLE WINAPI CreateThread(
_In_opt_ LPSECURITY_ATTRIBUTES lpThreadAttributes,
_In_ SIZE_T dwStackSize,
_In_ LPTHREAD_START_ROUTINE lpStartAddress,
_In_opt_ LPVOID lpParameter,
_In_ DWORD dwCreationFlags,
_Out_opt_ LPDWORD lpThreadId
);
實際調用時一般會像上述函數聲明一樣把CreateThread分好幾行寫,并且分別給每個參數后面加一段注釋,表明這里設置NULL或0是默認定義了什么參數,從而增加可讀性。雖然Win API往往是通過宏的OR運算來設置參數,而pthread則是通過庫函數來設置。
一般來說,這些參數采用默認就好了,底層API的特點是提供了很大的靈活性,但是使用起來非常麻煩,往往用戶會自己對底層API做一些簡單的封裝,尤其是C++可以直接調用C API,然后用默認函數參數的特性來讓用戶調用時少寫幾個默認參數。
C++11的方式
C++11新增了可變模板參數特性,使得實現剛才那段代碼非常容易。C++11的實現如下
// cpp11_thread_demo.cc
#include <iostream>
#include <string>
#include <thread>
void thread_substr(const std::string& s, size_t pos, size_t len) {
std::cout << s.substr(pos, len) << std::endl;
}
int main() {
std::string s = "Hello World";
std::thread t(thread_substr, s, 6, s.size() - 6);
t.join();
return 0;
}
雖然使用的是C++標準庫,但是用g++編譯時還是需要加上-pthread選項。
xyz@ubuntu:~$ g++ -std=c++11 cpp11_thread_demo.cc -pthread
xyz@ubuntu:~$ ./a.out
World
可以發(fā)現C++線程庫簡化了線程創(chuàng)建和連接的操作,去掉了平臺特定屬性的設置,如果實在時要對線程屬性進行精確控制,C++線程庫也提供了
thread::native_handle函數來取得平臺特定的線程句柄,比如對pthread而言就是pthread_t類型,對WinAPI而言就是HANDLE類型。
需要注意的是,對于類成員函數,傳入方式應該像下面這樣
struct Object {
void func(int i, double d) { std::cout << i << " " << d << std::endl; }
};
int main() {
Object obj;
std::thread t(&Object::func, obj, 1, 3.14);
t.join();
return 0;
}
第一個參數是類成員函數的地址(類型為void (Object::*)(int, double)),第二個參數是類的對象,之后才是成員函數的參數。
線程的分離(detach)和連接(join)
我之前的代碼均是2步:1. 構造線程對象;2. 調用對象的join()方法。
在線程對象被成功創(chuàng)建后(即傳入了一個線程函數和合適的參數),線程對象和線程函數是綁定在一起的,但是線程函數和父線程函數是分離的(即并行執(zhí)行)。但問題在于,父線程函數結束執(zhí)行時,函數作用域內的所有棧上的對象都會銷毀。
void func() {
std::thread t{ [] { std::cout << "Hello world!" << std::endl; } };
}
對上述代碼而言,func()在構造線程對象t后會立即結束,而與t相關的線程函數會花一段時間執(zhí)行完,因此在func()結束時,t離開了作用域而銷毀,而線程函數仍在執(zhí)行,此時便會出錯。
terminate called without an active exception
Aborted (core dumped)
所以在成功創(chuàng)建線程后,必須得執(zhí)行連接或分離操作。
- join()
對線程進行連接操作類似于Linux對進程的wait()操作,如果父線程調用join()時線程函數已經執(zhí)行完畢,立刻取得線程函數的返回值,否則一直等待線程函數執(zhí)行完畢才能執(zhí)行下一句。
void func() {
std::thread t{ [] {
sleep(1); // unistd.h
std::cout << "Hello world!" << std::endl;
} };
t.join();
std::cout << "thread ok!" << std::endl;
}
xyz@ubuntu:~$ g++ test.cc -std=c++11 -pthread
xyz@ubuntu:~$ ./a.out
Hello world!
thread ok!
可以看到,雖然子線程是休眠了1秒后才打印的,但是父線程是在它之后才打印。
使用join()典型的情況是:創(chuàng)建多個線程用來執(zhí)行類似的任務,父線程等所有子線程完成任務后才繼續(xù)執(zhí)行。比如創(chuàng)建多個線程進行爬蟲,等所有數據都爬完了再一起做處理。
- detach()
回顧之前提到的線程對象銷毀了而線程函數仍在執(zhí)行的狀態(tài),如果線程對象調用了detach()方法,那么它就可以“壽終正寢”了,即使之后它銷毀了,線程函數仍然可以繼續(xù)執(zhí)行。
使用detach()典型的情況是:在父線程中創(chuàng)建完子線程去執(zhí)行各自的任務,然后父線程繼續(xù)干自己的事情。比如每個子線程安排1個窗口來售票,執(zhí)行完畢會釋放窗口。父線程不用等待子線程結束時就要繼續(xù)接客,每次接客時檢查是否有空余窗口,若有則再創(chuàng)建子線程。
謹慎分離線程
將線程分離時需要十分謹慎。
首先,如果你在main()函數內部創(chuàng)建一個線程,然后分離,之后結束main()函數。由于main()函數返回時程序也會結束運行,即進程終止。進程終止意味著回收進程占用的虛擬內存空間,自然創(chuàng)建的子線程也不會脫離進程而運行。
int main() {
std::thread t{ []{ std::cout << "Hello world!" << std::endl; } };
t.detach();
return 0;
}
所以像上面這段代碼執(zhí)行結果會是什么也不輸出。
更需要注意的是下面這種情況
#include <stdio.h>
#include <unistd.h>
#include <thread> // for sleep()
void thread_func(const char* s) {
sleep(1); // 讓func()先退出
puts(s);
}
void func() {
char s[] = "hello";
std::thread t(thread_func, s);
t.detach();
}
int main() {
func();
sleep(2); // 等待線程執(zhí)行完畢
return 0;
}
注意字符數組s在func()調用結束之后就被回收了,而線程函數接收的參數s指向的是一段被回收的內存,訪問已經被回收的內存會產生預料之外的行為。
類似的行為還有,線程函數接收T&或者T*,而T類型的對象是分類在棧上的,如果線程分離后線程函數還在執(zhí)行,函數參數所引用或指向的對象就已經被回收了。
一種解決方式是拷貝一份數據,雖然這樣看起來開銷比較大。比如接收一個vector作為參數,如果vector包含元素很多,復制起來的開銷不小,而且大數據量的復制可能拋出bad_alloc異常。
另一種解決方式是在堆上new動態(tài)申請內存,然后傳遞指針。這樣的話內存的釋放就要交給線程函數了,那么在線程函數里就得十分小心謹慎地添加delete語句。
熟悉C++的同學自然可以想到,可以利用RAII來管理動態(tài)內存。將STL容器/智能指針(shared_ptr和unique_ptr)/字符串類(string)作為線程函數的參數。
使用RAII保證線程的join操作
std::thread t(func);
do_sth();
t.join();
上述代碼是典型的套路:C++11中創(chuàng)建子線程,之后主線程做自己的事情,之后等待子線程執(zhí)行完畢。
但是問題在于do_sth()可能會拋出異常,如果你要捕獲異常進行處理,代碼就變成了這樣
std::thread t;
try {
t = std::thread(func);
do_sth();
t.join();
} catch (std::exception& e) {
if (t.joinable()) {
t.join();
}
std::cerr << e.what() << "\n";
}
假如有多個catch語句,那么每個catch語句都要手動join線程。理想的狀況是線程對象t銷毀之前就讓它執(zhí)行join()操作。
因此可以定義下面這樣的類
class scoped_thread {
std::thread t;
public:
scoped_thread(std::thread t_) : t(std::move(t_)) {
if (!t.joinable()) {
throw std::logic_error("No thread!");
}
}
~scoped_thread() {
t.join();
}
scoped_thread(const scoped_thread&) = delete;
scoped_thread& operator=(const scoped_thread&) = delete;
};
然后創(chuàng)建線程的函數變?yōu)?/p>
try {
scoped_thread t(std::thread(func));
do_sth();
} catch (std::exception& e) {
std::cerr << e.what() << "\n";
}
上述代碼使用了移動操作將線程的所有權轉移到了scoped_thread對象內部的std::thread對象中,也可以先定義std::thread t(func);再構造scoped_thread st(std::move(t));,此時t就失去了對線程的控制權,t也變成了不可join的狀態(tài)。由于scoped_thread內部的線程對象是私有的,所以除了析構函數沒有任何措施能夠對線程執(zhí)行join操作。而析構函數是在scoped_thread對象離開作用域時自動調用的,所以相當于此時自動join。