概述
一組並發線程運行在同一進程上下文中,每個線程都有自己獨立的線程上下文,包括線程ID、棧、棧指針、程序計數器(PC)、條件碼和通用目的寄存器值。每個線程和其它線程一起共享進程上下文的其他部分,包括整個用戶虛擬地址空間(由代碼段、讀/寫數據、堆以及所有共享庫的代碼和數據區組成)。線程也共享打開的文件集合。
當存在共享資源的時候,對資源的訪問需要同步。這時候使用線程編寫程序的時候,需要編寫具有線程安全性(thread safety)屬性的函數。一個函數,當且僅當被多個並發線程反復調用時,能夠一直產生正確的結果,才能夠被稱為線程安全的(thread-safe),否則我們稱其為非線程安全的(thread-unsafe)。
四類線程不安全函數
第1類;不保護共享變量的函數
對共享變量的並發訪問會造成競爭,請看下面的例子:
// badCounter.c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include "PV.h"
void *thread(void *vargp);
volatile int Counter = 0;
int main(int argc, char **argv)
{
int niters;
pthread_t tid1, tid2;
if(argc != 2)
{
fprintf(stderr, "Usage : %s <niters>\n", argv[0]);
exit(0);
}
niters = atoi(argv[1]);
pthread_create(&tid1, NULL, thread, &niters);
pthread_create(&tid2, NULL, thread, &niters);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
if(Counter != 2 * niters)
fprintf(stderr, "BOOM! Counter = %d\n", Counter);
else
fprintf(stdout, "OK! Counter = %d\n", Counter);
exit(0);
}
void *thread(void *vargp)
{
int i, niters = *((int*)vargp);
for(i = 0; i < niters; ++i)
Counter++;
return NULL;
}
運行一下這個例子:
$ gcc -pthread badCounter.c -o badCounter
$ ./badCounter 100000000
BOOM! cnt = 177953661
$ ./badCounter 100000000
BOOM! cnt = 185993828
$ ./badCounter 100000000
BOOM! cnt = 176414058
如果我們每次運行這個例子,結果可能都不一致,原因是:對Counter的訪問存在競爭。
解決這類問題的方法是:利用像P和V操作這樣的同步操作來保護共享的變量。這樣做有好處也有壞處:
- 優點是在調用程序中不需要做任何修改
- 缺點是同步操作將增加程序的執行時間
第2類:依賴於跨越多個調用的狀態的函數
一個偽隨機數生成器設這類線程不安全函數的簡單例子:
unsigned int next = 1;
/* rand - 返回一個在[0, 32767]范圍內的偽隨機數*/
int rand()
{
next = next * 1103515245 + 12345;
return (unsigned int)(next / 65536) % 32768;
}
/* srand - 為rand函數設置隨機種子*/
void srand(unsigned int seed)
{
next = seed;
}
rand
函數是線程不安全的,因為調用當前調用的結果依賴於前次調用的中間結果。當調用srand
為rand
設置一個種子后,我們從一個單線程中反復調用rand
,能夠預期得到一個可重復的隨機數字序列。然而在多線程調用rand
函數,這種假設就不再成立了。
解決這一類問題唯一的方法是重寫它,使得它不再使用任何static數據,而是依靠調用者在參數中傳遞狀態信息。這樣做的缺點很明顯:現在需要被迫修改調用rand
的代碼。在一個大的程序中,可能有成百上千不同的調用位置,做這樣的修改將是非常麻煩的,而且容易出錯。
第3類:返回指向靜態變量的指針的函數
一些函數,如ctime和gethostbyname,將計算結果存放在一個static變量中,然后返回一個指向這種變量的指針。如果我們從並發線程中調用這些函數,那么將可能發生災難,因為正在被一個線程使用的結果會被另一個線程悄悄覆蓋了。
有兩種方法來處理這類線程不安全函數:
- 第一種選擇是重寫函數,使得調用者傳遞存放結果的變量的地址,這就消除了所有共享數據,但是這要求程序員能夠修改函數的源代碼。
- 第二種選擇是使用加鎖-拷貝(lock-and-copy)技術。如果線程不安全函數難以修改或者不能修改(如代碼過於復雜或者無法獲得其源代碼),可以采用這種方式。加鎖-拷貝的基本思想是將線程不安全函數與互斥鎖聯系起來。在每個調用位置,對互斥鎖加鎖,調用線程不安全函數 ,將函數返回的結果拷貝到一個私有的存儲器位置,然后對互斥鎖解鎖。為盡可能減少對調用者的修改,應該定義一個線程安全的包裝函數,它執行加鎖-拷貝,然后通過調用這個包裝函數來取代對線程不安全函數的調用。
下面利用加鎖-拷貝技術,給出ctime的一個線程安全的版本:
char *ctime_TS(const time_t *timep, char *privatep)
{
char *sharedp;
P(&mutex); // 加鎖
sharedp = ctime(timep);
strcpy(privatep, sharedp); // 拷貝到私有的存儲器空間
V(&mutex); // 解鎖
return privatep;
}
第4類:調用線程不安全函數的函數
如果函數f調用線程不安全函數g,那么f不一定是線程不安全的。
- 如果g是第2類函數(依賴於跨越多個調用的狀態),那么f是線程不安全的函數。除了重寫g以外,沒有其他辦法。
- 如果g是第1類或第3類函數,那么只要用一個互斥鎖保護調用位置和任何得到的共享數據,f仍然可能是線程安全的。上面的ctime和ctime_ts就是個很好的例子。
以下是一些線程不安全函數,以及它們的對應的線程安全版本。在編寫並發線程程序的時候,盡可能使用線程安全版本的函數。
可重入性
有一類重要的線程安全函數,叫做可重入函數(reentrant function),其特點在於它們具有這樣一種屬性:當它們被多個線程調用時,不會引用任何共享數據。所有函數的集合被划分成不相交的線程安全和線程不安全函數集合。可重入函數集合是線程安全函數集合的一個真子集。它們的關系如下圖所示。
可重入函數通常要比不可重入函數的線程安全的函數高效一些,因為它們不需要同步操作!更進一步說,線程不安全函數rand
轉化為可重入函數,就不要引用外部的共享變量,我們可以用一個調用者傳遞進來的指針取代靜態的next變量:
/* rand_r 是一個可重入的偽隨機數生成函數*/
int rand_r(unsigned int *nextp)
{
*nextp = *nextp * 1103515245 + 12345;
return (unsigned int)(*next / 65536) % 32768;
}
上面的rand_r是可重入的嗎?不一定。上面說過了,可重入函數不引用任何共享變量,但是我們不能夠保證調用者傳遞進來的nextp不是指向一個共享變量(比如全局變量或靜態變量)!
實際上,我們所說的可重入函數包含兩類:顯式可重入函數(explicitly reentrant function)和隱式可重入函數(implicitly reentrant function)
顯式可重入函數
如果所有的函數參數都是傳值傳遞的(即沒有指針),並且所有的數據引用都是本地的自動棧變量(即沒有引用全局或靜態變量),那么函數就是顯示可重入的。也就是說,無論它是被如何調用的,我們都可以斷言它是可重入的。
隱式可重入函數
如果允許顯式可重入函數中的一些參數是引用傳遞的(即允許傳遞指針),那么就得到一個隱式可重入函數。也就是說,如果調用線程小心地傳遞指向非共享數據的指針,那么它是可重入的。例如上面的rand_r
就是隱式可重入的。
通過上面的分析,有一個點值得注意:
- 可重入性有時候既是調用者也是被調用者的屬性,並不只是被調用者單獨的屬性。
參考資料
- RandalE.Bryant, DavidR.O’Hallaron, 布賴恩特,等. 深入理解計算機系統[M]. 機械工業出版社, 2011.
- W.RichardStevens, StephenA.Rago, 史蒂文斯, 等. UNIX 環境高級編程 [M]. 人民郵電出版社, 2014.