Linux/Unix程式設計中的執行緒安全問題
在目前的電腦科學中,執行緒是作業系統排程的最小單元,程序是資源分配的最小單元。在大多數作業系統中,一個程序可以同時派生出多個執行緒。這些執行緒獨立執行,共享程序的資源。在單處理器系統中,多執行緒通過分時複用技術來技術,處理器在不同的執行緒間切換,從而更高效地利用系統 CPU資源。在多處理器和多核系統中,執行緒實際上可以同時執行,每個處理器或者核可以執行一個執行緒,系統的運算能力相對於單執行緒或者單程序大幅增強。
多執行緒技術讓多個處理器機器,多核機器和集群系統執行更快。因為多執行緒模型與生俱來的優勢可以使這些機器或者系統實現真實地的併發執行。但多執行緒在帶來便利的同時,也引入一些問題。執行緒主要由控制流程和資源使用兩部分構成,因此一個不得不面對的問題就是對共享資源的訪問。為了確保資源得到正確的使用,開發人員在設計編寫程式時需要考慮避免競爭條件和死鎖,需要更多地考慮使用執行緒互斥變數。
執行緒安全 (Thread-safe) 的函式就是一個在程式碼層面解決上述問題比較好的方法,也成為多執行緒程式設計中的一個關鍵技術。如果在多執行緒併發執行的情況下,一個函式可以安全地被多個執行緒併發呼叫,可以說這個函式是執行緒安全的。反之,則稱之為“非執行緒安全”函式。注意:在單執行緒環境下,沒有“執行緒安全”和“非執行緒安全”的概念。因此,一個執行緒安全的函式允許任意地被任意的執行緒呼叫,程式開發人員可以把主要的精力在自己的程式邏輯上,在呼叫時不需要考慮鎖和資源訪問控制,這在很大程度上會降低軟體的死鎖故障和資源併發訪問衝突的機率。所以,開發人員應儘可能編寫和呼叫執行緒安全函式。
判斷一個函式是否執行緒安全不是一件很容易的事情。但是讀者可以通過下面這幾條確定一個函式是執行緒不安全的。
- a, 函式中訪問全域性變數和堆。
- b, 函式中分配,重新分配釋放全域性資源。
- c, 函式中通過控制代碼和指標的不直接訪問。
- d, 函式中使用了其他執行緒不安全的函式或者變數。
因此在編寫執行緒安全函式時,要注意兩點:
- 1, 減少對臨界資源的依賴,儘量避免訪問全域性變數,靜態變數或其它共享資源,如果必須要使用共享資源,所有使用到的地方必須要進行互斥鎖 (Mutex) 保護;
- 2, 執行緒安全的函式所呼叫到的函式也應該是執行緒安全的,如果所呼叫的函式不是執行緒安全的,那麼這些函式也必須被互斥鎖 (Mutex) 保護;
舉個例子(參考 例子 1),下面的這個函式 sum()是執行緒安全的,因為函式不依賴任何全域性變數。
int sum(int i, int j) {
return (i+j);
}
|
但如果按下面的方法修改,sum()就不再是執行緒安全的,因為它呼叫的函式 inc_sum_counter()不是執行緒安全的,該函式訪問了未加鎖保護的全域性變數 sum_invoke_counter。這樣的程式碼在單執行緒環境下不會有任何問題,但如果呼叫者是在多執行緒環境中,因為 sum()有可能被併發呼叫,所以全域性變數 sum_invoke_counter很有可能被併發修改,從而導致計數出錯。
static int sum_invoke_counter = 0;
void inc_sum_counter(int i, int j) {
sum_invoke_counter++;
}
int sum(int i, int j) {
inc_sum_counter();
return (i+j);
}
|
我們可通過對全域性變數 sum_invoke_counter新增鎖保護,使得 inc_sum_counter()成為一個執行緒安全的函式。
static int sum_invoke_counter = 0;
static pthread_mutex_t sum_invoke_counter_lock = PTHREAD_MUTEX_INITIALIZER;
void inc_sum_counter(int i, int j) {
pthread_mutex_lock( &sum_invoke_counter_lock );
sum_invoke_counter++;
pthread_mutex_unlock( &sum_invoke_counter_lock );
}
int sum(int i, int j) {
inc_sum_counter();
return (i+j);
}
|
現在 , sum()和 inc_sum_counter()都成為了執行緒安全函式。在多執行緒環境下,sum()可以被併發的呼叫,但所有訪問 inc_sum_counter()執行緒都會在互斥鎖 sum_invoke_counter_lock上排隊,任何一個時刻都只允許一個執行緒修改 sum_invoke_counter,所以 inc_sum_counter()就是現成安全的。
除了執行緒安全還有一個很重要的概念就是 可重入(Re-entrant),所謂可重入,即:當一個函式在被一個執行緒呼叫時,可以允許被其他執行緒再呼叫。顯而易見,如果一個函式是可重入的,那麼它肯定是執行緒安全的。但反之未然,一個函式是執行緒安全的,卻未必是可重入的。程式開發人員應該儘量編寫可重入的函式。
一個函式想要成為可重入的函式,必須滿足下列要求:
- a) 不能使用靜態或者全域性的非常量資料
- b) 不能夠返回地址給靜態或者全域性的非常量資料
- c) 函式使用的資料由呼叫者提供
- d) 不能夠依賴於單一資源的鎖
- e) 不能夠呼叫非可重入的函式
對比前面的要求,例子 1的 sum()函式是可重入的,因此也是執行緒安全的。例子 3中的 inc_sum_counter()函式雖然是執行緒安全的,但是由於使用了靜態變數和鎖,所以它是不可重入的。因為 例子 3中的 sum()使用了不可重入函式 inc_sum_counter(), 它也是不可重入的。
如果把一個非執行緒安全的函式作為執行緒安全對待,那麼結果可能是無法預料的,例如 下面這段程式碼是對 basename()的錯誤用法:
#include <unistd.h>
#include <stdio.h>
#include <stdarg.h>
#include <pthread.h>
#include <string.h>
#include <libgen.h>
void printf_sa(char *fmt, ...) {
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
va_list args;
va_start(args, fmt);
pthread_mutex_lock(&lock);
vprintf(fmt, args);
pthread_mutex_unlock(&lock);
va_end(args);
}
void* basename_test(void *arg) {
pthread_t self = pthread_self();
char *base = basename((char*)arg);
printf_sa("TI-%u: base: %s/n", self, base);
}
int main(int argc, char *argv) {
int i = 0;
pthread_t tids[2];
char msg[1024];
strcpy(msg, "/tmp/test");
pthread_create(&tids[0], NULL, basename_test, msg);
msg[7] -= 32;
pthread_create(&tids[1], NULL, basename_test, msg);
pthread_join(tids[0], NULL);
pthread_join(tids[1], NULL);
return 0;
}
|
這段程式碼的意思是在兩個併發的執行緒中同時執行函式 basename(),然後打印出對於路徑 "/tmp/test"和"/tmp/teSt"的結果。
編譯 ( 注意:如編譯器提示 pthread_create函式不能找到可能需要連線庫 pthread,請需新增 -lpthread選項 ) 執行這段程式碼,你會發現大部分情況下螢幕上都會列印類似出如下結果 :
TI-3086846864: base: teSt
TI-3076357008: base: teSt
|
實際上我們期待的值應該 :
TI-3086846864: base: test
TI-3076357008: base: teSt
|
雖然只是一個字母的差別,但這其實涉及到函式 basename()的執行緒安全特徵。造成這個問題的原因是函式 basename()的返回值指向了輸入字串的一個片段,所以當輸入字串發生變化以後,basename()的返回值也發生了變化。
因為 basename()的函式宣告只提供了一個引數,所以該函式不得不通過修改輸入引數或使用靜態變數的方式來將結果返回給使用者。
參考 Linux幫助手冊,dirname()和 basename()可能會修改傳入的引數字串,所以呼叫者應該傳入引數字串的拷貝。並且,這兩個函式的返回值可能指向靜態分配的記憶體,所以其返回值的內容有可能被隨後的 dirname()或 basename()呼叫修改。
因為多執行緒技術和執行緒安全概念出現得相對較晚,所以 POSIX規範中收納的一些函式並不符合執行緒安全要求。
下表是 UNIX環境高階程式設計列出 POSIX.1規範中的非執行緒安全的函式:
asctime | ecvt | gethostent | getutxline | putc_unlocked |
---|---|---|---|---|
basename | encrypt | getlogin | gmtime | putchar_unlocked |
catgets | endgrent | getnetbyaddr | hcreate | putenv |
crypt | endpwent | getnetbyname | hdestroy | pututxline |
ctime | endutxent | getopt | hsearch | rand |
dbm_clearerr | fcvt | getprotobyname | inet_ntoa | readdir |
dbm_close | ftw | getprotobynumber | L64a | setenv |
dbm_delete | getcvt | getprotobynumber | lgamma | setgrent |
dbm_error | getc_unlocked | getprotoent | lgammaf | setkey |
dbm_fetch | getchar_unlocked | getpwent | lgammal | setpwent |
dbm_firstkey | getdate | getpwnam | localeconv | setutxent |
dbm_nextkey | getenv | getpwuid | lrand48 | strerror |
dbm_open | getgrent | getservbyname | mrand48 | strtok |
dbm_store | getgrgid | getservbyport | nftw | ttyname |
dirname | getgrnam | getservent | nl_langinfo | unsetenv |
dlerror | gethostbyaddr | getutxent | ptsname | wcstombs |
drand48 | gethostbyname | getutxid | ptsname | ectomb |
目前大部分上述函式目前已經有了對應的執行緒安全版本的實現,例如:針對 getpwnam的 getpwnam_r(),( 這裡的 _r表示可重入 (reentrant),如前所述,可重入的函式都是執行緒安全的)。在多執行緒軟體開發中,如果需要使用到上所述函式,應優先使用它們對應的執行緒安全版本。而對於某些沒有執行緒安全版本的函式,開發人員可按自己需要編寫執行緒安全版本的實現。
在編寫自己的執行緒安全版本函式之前,應首先仔細閱讀 POSIX標準對函式的定義,以及通過充分的測試熟悉函式的輸入和輸出。理論上來說,所有的執行緒安全的版本函式應該與非執行緒安全版本函式在單執行緒環境下表現一致。
這裡給出一個針對 basename()的執行緒安全版本的例子。
在熟悉了 basename() 函式的功能之後,下面是一個執行緒安全版本的實現。
/* thread-safe version of basename() */
char* basename_ta(char *path, char *buf, int buflen) {
#define DEFAULT_RESULT_DOT "."
#define DEFAULT_RESULT_SLASH "/"
/* 如果輸入的路徑長度小於 PATH_MAX,
* 則使用自動變數 i_fixed_bufer 作為內部緩衝區 ,
* 否則申請堆記憶體做為字串存放緩衝區。
*/
char i_fixed_buf[PATH_MAX+1];
const int i_fixed_buf_len = sizeof(i_fixed_buf)/sizeof(char);
char *result = buf;
char *i_buf = NULL;
int i_buf_len = 0;
int adjusted_path_len = 0;
int path_len = 0;
int i, j;
char tmp = 0;
if (path == NULL) {
/* 如果輸入為空指標,則直接返回當前目錄 */
path = DEFAULT_RESULT_DOT;
}
/* 分配內部緩衝區用來存放輸入字串 */
path_len = strlen(path);
if ((path_len + 1) > i_fixed_buf_len) {
i_buf_len = (path_len + 1);
i_buf = (char*) malloc(i_buf_len * sizeof(char));
} else {
i_buf_len = i_fixed_buf_len;
i_buf = i_fixed_buf;
}
/* 拷貝字串到緩衝區,以便接下來對字串做預處理 */
strcpy(i_buf, path);
adjusted_path_len = path_len;
/* 預處理:刪除路徑未的路徑符號 '/'; */
if (adjusted_path_len > 1) {
while (i_buf[adjusted_path_len-1] == '/') {
if (adjusted_path_len != 1) {
adjusted_path_len--;
} else {
break;
}
}
i_buf[adjusted_path_len] = '/0';
}
/* 預處理:摺疊最後出現的連續 '/'; */
if (adjusted_path_len > 1) {
for (i = (adjusted_path_len -1), j = 0; i >= 0; i--) {
if (j == 0) {
if (i_buf[i] == '/')
j = i;
} else {
if (i_buf[i] != '/') {
i++;
break;
}
}
}
if (j != 0 && i < j) {
/* 摺疊多餘的路徑符號 '/';
*/
strcpy(i_buf+i, i_buf+j);
}
adjusted_path_len -= (j - i);
}
/* 預處理:尋找最後一個路徑符號 '/' */
for (i = 0, j = -1; i < adjusted_path_len; i++) {
if (i_buf[i] == '/')
j = i;
}
/* 查詢 basename */
if (j >= 0) {
/* found one '/' */
if (adjusted_path_len == 1) { /* 輸入的是跟路徑 ("/"),則返回根路徑 */
if (2 > buflen) {
return NULL;
} else {
strcpy(result, DEFAULT_RESULT_SLASH);
}
} else {
if ((adjusted_path_len - j) > buflen) { /* 緩衝區不夠,返回空指標 */
result = NULL;
} else {
strcpy(result, (i_buf+j+1));
}
}
} else {
/* no '/' found */
if (adjusted_path_len == 0) {
if (2 > buflen) { /* 如果傳入的引數為空字串 ("") */
return NULL; /* 直接返回當前目錄 (".") */
} else {
strcpy(result, DEFAULT_RESULT_DOT);
}
} else {
if ((adjusted_path_len+1) > buflen) {
result = NULL; /* 緩衝區不夠,返回空指標 */
} else {
strcpy(result, i_buf); /* 拷貝整個字串做為返回值 */
}
}
}
if (i_buf_len != i_fixed_buf_len) { /* 釋放緩衝區 */
free(i_buf);
i_buf = NULL;
}
return result;
}
|
這個執行緒安全版本的函式將處理結果儲存在外部分配的記憶體中,所以函式內部並無對全域性資源的再依賴。因此,這個函式可安全地被多個執行緒所使用。
POSIX標準在 Linux和 AIX平臺上表現一致,以上所講述的執行緒安全內容均可適用於 AIX多執行緒程式設計環境。