第4章 C++多線程系統編程精要
4.1 引言
學習多線程編程面臨的最大的思維方式的轉變有以下兩點:
- 當前線程可能隨時會被切換出去,或者說被搶占(preempt)了
- 多線程程序中事件的發生順序不再有全局統一的先后關系
多線程程序的正確性不能依賴于任何一個線程的執行速度,不能通過原地等待(sleep())來假定其他線程的事件已經發生,而必須通過適當的同步來讓當前線程能看到其他線程的事件的結果。無論線程執行得快與慢(被操作系統切換出去得越多,執行越慢),程序都應該能正常工作。
例如下面這段代碼就有這方面的問題。
bool running = false;//全局標志
void threadFunc() {while(running){//get task from queue}
}
void start() {muduo::Thread t(threadFunc);t.start();running = true;//應該放到t.start()之前
}
- 這段代碼暗中假定線程函數的啟動慢于running變量的賦值,因此線程函數能進入while循環執行我們想要的功能。
- 但是,直到有一天,系統負載很高,Thread::start()調用pthread_create()陷入內核后返回時,內核決定換另外一個就緒任務來執行。于是running的賦值就推遲了,這時線程函數就可能不進入while循環而直接退出了。
- 有人會認為在while之前加一小段延時(sleep)就能解決問題,但這是錯的,無論加多大的延時,系統都有可能先執行while的條件判斷,然后再執行running的賦值。
- 正確的做法是把running的賦值放到t.start()之前,這樣借助pthread_create()的happens-before語意來保證running的新值能被線程看到。
4.2 基本線程原語的選用
POSIX threads的函數有110多個,真正常用的不過十幾個。而且在C++程序中通常會有更為易用的 wrapper,不會直接調用Pthreads函數。
這11個最基本的Pthreads函數是:
- 2個:線程的創建和等待結束(join)。封裝為 muduo::Thread。
- 4個:mutex的創建、銷毀、加鎖、解鎖。封裝為 muduo::MutexLock。
- 5個:條件變量的創建、銷毀、等待、通知、廣播。封裝為 muduo::Condition。
用這三樣東西(thread、mutex、condition)可以完成任何多線程編程任務。當然我們一般也不會直接使用它們(mutex除外),而是使用更高層的封裝,例如 mutex::ThreadPool 和 mutex::CountDownLatch 等。
除此之外,Pthreads還提供了其他一些原語,有些是可以酌情使用的,有些則是不推薦使用的。
可以酌情使用的有:
- pthread_once,封裝為muduo::Singleton。其實不如直接用全局變量。
- pthread_key*,封裝為muduo::ThreadLocal。可以考慮用__thread替換之。
不建議使用:
- pthread_rwlock,讀寫鎖通常應慎用。muduo沒有封裝讀寫鎖,這是有意的。
- sem_*,避免用信號量(semaphore)。它的功能與條件變量重合,但容易用錯。
- pthread_{cancel, kill}。程序中出現了它們,則通常意味著設計出了問題。
不推薦使用讀寫鎖的原因是它往往造成提高性能的錯覺(允許多個線程并發讀),實際上在很多情況下,與使用最簡單的mutex相比,它實際上降低了性能。另外,寫操作會阻塞讀操作,如果要求優化讀操作的延遲,用讀寫鎖是不合適的。
多線程系統編程的難點不在于學習線程原語(primitives),而在于理解多線程與現有的C/C++庫函數和系統調用的交互關系,以進一步學習如何設計并實現線程安全且高效的程序。
4.3 C/C++系統庫的線程安全性
現行的C/C++標準(C89/C99/C++03)并沒有涉及線程。
新版的C/C++標準(C11和C++11)規定了程序在多線程下的語意,C++11還定義了一個線程庫(std::thread)。
對于標準而言,關鍵的不是定義線程庫,而是規定內存模型(memory model)。特別是規定一個線程對某個共享變量的修改何時能被其他線程看到,這稱為內存序(memory ordering)或者內存能見度(memory visibility)。
線程的出現給出現在20世紀90年代Unix操作系統的系統函數庫帶來了沖擊,破壞了20年來一貫的編程傳統和假定。
例如:
- errno不再是一個全局變量,因為每個線程可能會執行不同的系統庫函數。
- 有些“純函數”不受影響,例如memset/strcpy/snprintf等等。
- 有些影響全局狀態或者有副作用的函數可以通過加鎖來實現線程安全,例如malloc/free、printf、fread/fseek等等。
- 有些返回或使用靜態空間的函數不可能做到線程安全,因此要提供另外的版本,例如asctime_r/ctime_r/gmtime_r、stderror_r、strtok_r等等。
- 傳統的fork()并發模型不再適用于多線程程序
現在Linux glibc把errno定義為一個宏,注意errno是一個lvalue,因此不能簡單定義為某個函數的返回值,而必須定義為對函數返回指針的dereference。
extern int *__errno_location(void);
#define errno (*__errno_location())
現在glibc庫函數大部分都是線程安全的。特別是FILE*
系列函數是安全的,glibc甚至提供了非線程安全的版本以應對某些特殊場合的性能需求。
盡管單個函數是線程安全的,但兩個或多個函數放到一起就不再安全了。
例如fseek()和fread()都是安全的
- 但是對某個文件“先seek再read”這兩步操作中間有可能會被打斷,其他線程有可能趁機修改了文件的當前位置,讓程序邏輯無法正確執行。
- 在這種情況下,我們可以用
flockfile(FILE*)
和funlockfile(FILE*)
函數來顯式地加鎖。并且由于FILE*
的鎖是可重入的,加鎖之后再調用fread()
不會造成死鎖。
如果程序直接使用lseek和read這兩個系統調用來隨機讀取文件,也存在“先seek再read”這種race condition,但是似乎我們無法高效地對系統調用加鎖。解決辦法是改用pread系統調用,它不會改變文件的當前位置。
由此可見,編寫線程安全程序的一個難點在于線程安全是不可組合的(composable),一個函數foo()調用了兩個線程安全的函數,而這個foo()函數本身很可能不是線程安全的。即便現在大多數glibc庫函數是線程安全的,我們也不能像寫單線程程序那樣編寫代碼。
例如,在單線程程序中,如果我們要臨時轉換時區,可以用tzset()函數,這個函數會改變程序全局的“當前時區”。
// 保存當前的時區設置
string oldTz = getenv("TZ");
// 設置時區為歐洲倫敦 (Europe/London)
putenv("TZ=Europe/London");
// 更新時區設置
tzset();// 定義一個結構體用于存儲倫敦的本地時間
struct tm localTimeInLN;
// 獲取當前時間戳
time_t now = time(NULL);
// 將當前時間戳轉換為倫敦時區的本地時間,并存儲在localTimeInLN 中
localtime_r(&now, &localTimeInLN);
// 恢復之前保存的時區設置
setenv("TZ", oldTz.c_str(), 1);
// 更新時區設置,使其回到之前的設置
tzset();
但是在多線程程序中,這么做不是線程安全的,即便tzset()本身是線程安全的。
因為它改變了全局狀態(當前時區),這有可能影響其他線程轉換當前時間,或者被其他進行類似操作的線程影響。
解決辦法是使用muduo::TimeZone class,每個immutable instance對應一個時區,這樣時間轉換就不需要修改全局狀態了。
例如:
// 自定義 TimeZone 類
class TimeZone {
public:// 構造函數,接受時區文件路徑explicit TimeZone(const char* zonefile);// 將時間戳轉換為特定時區的本地時間struct tm toLocalTime(time_t secondsSinceEpoch) const;// 將特定時區的本地時間轉換為時間戳time_t fromLocalTime(const struct tm&) const;// 其他可能的成員函數...
};// 定義常量表示紐約時區和倫敦時區
const TimeZone kNewYorkTz("/usr/share/zoneinfo/America/New_York");
const TimeZone kLondonTz("/usr/share/zoneinfo/Europe/London");// 獲取當前時間戳
time_t now = time(NULL);
// 將當前時間戳轉換為紐約時區的本地時間
struct tm localTimeInNY = kNewYorkTz.toLocalTime(now);
// 將當前時間戳轉換為倫敦時區的本地時間
struct tm localTimeInLN = kLondonTz.toLocalTime(now);
一個基本思路是盡量把class設計成immutable的,這樣用起來就不必為線程安全操心了。
盡管C++03標準沒有明說標準庫的線程安全性,但我們可以遵循
- 一個基本原則:凡是非共享的對象都是彼此獨立的,如果一個對象從始至終只被一個線程用到,那么它就是安全的。
- 一個事實標準:共享的對象的read-only操作是安全的,前提是不能有并發的寫操作。
例如:
- 兩個線程各自訪問自己的局部vector對象是安全的;
- 同時訪問共享的const vector對象也是安全的,但是這個vector不能被第三個線程修改。一旦有writer,那么read-only操作也必須加鎖,例如vector::size()。
C++的標準庫容器和std::string都不是線程安全的,只有std::allocator保證是線程安全的。一方面的原因是為了避免不必要的性能開銷,另一方面的原因是單個成員函數的線程安全并不具備可組合性(composable)。
假設有safe_vectorclass,它的接口與std::vector相同,不過每個成員函數都是線程安全的(類似Javasynchronized方法)。但是用safe_vector并不一定能寫出線程安全的代碼。
例如:
safe_vector<int> vec;//全局可見
if(!vec.empty()) { //沒有加鎖保護int x = vec[0];//這兩步在多線程下是不安全的
}
在if語句判斷vec非空之后,別的線程可能清空其元素,從而造成vec[0]失效。
C++標準庫中的絕大多數泛型算法是線程安全的,因為這些都是無狀態純函數。只要輸入區間是線程安全的,那么泛型函數就是線程安全的。
C++的iostream不是線程安全的,因為流式輸出
std::cout << "Now is " << time(NULL);
等價于兩個函數調用
std::cout.operator<<("Now is ").operator<<(time(NULL));
即便ostream::operator<<()做到了線程安全,也不能保證其他線程不會在兩次函數調用之前向stdout輸出其他字符。
對于“線程安全的stdout輸出”這個需求,我們可以改用printf,以達到安全性和輸出的原子性。但是這等于用了全局鎖,任何時刻只能有一個線程調用printf,恐怕不見得高效。
4.4 Linux上的線程標識
POSIX threads庫提供了pthread_self函數用于返回當前進程的標識符,其類型為pthread_t。pthread_t不一定是一個數值類型(整數或指針),也有可能是一個結構體,因此Pthreads專門提供了pthread_equal函數用于對比兩個線程標識符是否相等。
這就帶來一系列問題,包括:
- 無法打印輸出pthread_t,因為不知道其確切類型。也就沒法在日志中用它表示當前線程的id。
- 無法比較pthread_t的大小或計算其hash值,因此無法用作關聯容器的key。
- 無法定義一個非法的pthread_t值,用來表示絕對不可能存在的線程id,因此MutexLock class沒有辦法有效判斷當前線程是否已經持有本鎖。
- pthread_t值只在進程內有意義,與操作系統的任務調度之間無法建立有效關聯。比方說在/proc文件系統中找不到pthread_t對應的task。
glibc的Pthreads實現實際上把pthread_t用作一個結構體指針(它的類型是unsigned long),指向一塊動態分配的內存,而且這塊內存是反復使用的。
這就造成pthread_t的值很容易重復。Pthreads只保證同一進程之內,同一時刻的各個線程的id不同;不能保證同一進程先后多個線程具有不同的id,更不要說一臺機器上多個進程之間的id唯一性了。
例如下面這段代碼中先后兩個線程的標識符是相同的:
int main(){pthread_t t1,t2;pthread_create(&t1,NULL,threadFunc,NULL);printf("%lx\n",t1);pthread_join(t1,NULL);pthread_create(&t2,NULL,threadFunc,NULL);printf("%lx\n",t2);pthread_join(t2,NULL);
}
$ ./a.out
7fad11787700
7fad11787700
因此,pthread_t并不適合用作程序中對線程的標識符。
在Linux上,作者建議使用gettid系統調用的返回值作為線程id,這么做的好處有:
- 它的類型是pid_t,其值通常是一個小整數13,便于在日志中輸出。
- 在現代Linux中,它直接表示內核的任務調度id,因此在/proc文件系統中可以輕易找到對應項:/proc/tid或/prod/pid/task/tid。
- 在其他系統工具中也容易定位到具體某一個線程,例如在top中我們可以按線程列出任務,然后找出CPU使用率最高的線程id,再根據程序日志判斷到底哪一個線程在耗用CPU。
- 任何時刻都是全局唯一的,并且由于Linux分配新pid采用遞增輪回辦法,短時間內啟動的多個線程也會具有不同的線程id。
- 0是非法值,因為操作系統第一個進程init的pid是1。
但是glibc并沒有封裝這個系統調用,需要我們自己實現。
作者封裝的gettid的方法如下:
muduo::CurrentThread::tid()采取的辦法是用__thread變量來緩存gettid的返回值,這樣只有在本線程第一次調用的時候才進行系統調用,以后都是直接從thread local緩存的線程id拿到結果,效率無憂。
未完待續。。。