如果要對函數進行並發訪問,不管是通過線程還是通過進程,您都可能會遇到函數不可重新進入所導致的問題。在本文中,通過範例程式碼瞭解如果可重新進入性不能得到保證會產生何種異常,尤其要注意訊號。引入了五條可取的編程經驗,並對提出的編譯器模型進行了討論,在這個模型中,可重新進入性由編譯器前端處理。
在早期的編程中,不可重新進入性對程式員並不構成威脅;函數不會有並發訪問,也沒有中斷。在很多較老的 C 語言實現中,函數被認為是在單線程進程的環境中運行。
不過,現在,並發編程已普遍使用,您需要意識到這個缺陷。本文描述了在並行和並發程式設計中函數的不可重新進入性導致的一些潛在問題。訊號的產生和處理尤其增加了額外的複雜性。由於訊號在本質上是非同步,所以難以找出當訊號處理函數觸發某個不可重新進入函數時導致的 bug。
本文:
- 定義了可重新進入性,並包含一個可重新進入函數的 POSIX 清單。
- 給出了樣本,以說明不可重新進入性所導致的問題。
- 指出了確保底層函數的可重新進入性的方法。
- 討論了在編譯器層次上對可重新進入性的處理。
什麼是可重新進入性?
可重新進入(reentrant)函數可以由多於一個任務並發使用,而不必擔心資料錯誤。相反, 不可重新進入(non-reentrant)函數不能由超過一個任務所共用,除非能確保函數的互斥(或者使用訊號量,或者在代碼的關鍵區段禁用中斷)。可重新進入函數可以在任意時刻被中斷,稍後再繼續運行,不會遺失資料。可重新進入函數要麼使用本地變數,要麼在使用全域變數時保護自己的資料。
可重新進入函數:
- 不為連續的調用持有待用資料。
- 不返回指向待用資料的指標;所有資料都由函數的調用者提供。
- 使用本機資料,或者通過製作全域資料的本地拷貝來保護全域資料。
- 絕不調用任何不可重新進入函數。
不要混淆可重新進入與安全執行緒。在程式員看來,這是兩個獨立的概念:函數可以是可重新進入的,是安全執行緒的,或者二者皆是,或者二者皆非。不可重新進入的函數不能由多個線程使用。另外,或許不可能讓某個不可重新進入的函數是安全執行緒的。
IEEE Std 1003.1 列出了 118 個可重新進入的 UNIX 函數,在此沒有給出副本。參見 參考資料 中指向 unix.org 上此列表的連結。
出於以下任意某個原因,其餘函數是不可重新進入的:
- 它們調用了
malloc 或 free。
- 眾所周知它們使用了待用資料結構體。
- 它們是標準 I/O 程式庫的一部分。
訊號和不可重新進入函數
訊號(signal) 是軟體中斷。它使得程式員可以處理非同步事件。為了向進程發送一個訊號,核心在進程表條目的訊號域中設定一個位,對應於收到的訊號的類型。訊號函數的 ANSI C 原型是:
void (*signal (int sigNum, void (*sigHandler)(int))) (int);
|
或者,另一種描述形式:
typedef void sigHandler(int);SigHandler *signal(int, sigHandler *);
|
當進程處理所捕獲的訊號時,正在執行的正常指令序列就會被訊號處理器臨時中斷。然後進程繼續執行,但現在執行的是訊號處理器中的指令。如果訊號處理器返回,則進程繼續執行訊號被捕獲時正在執行的正常的指令序列。
現在,在訊號處理器中您並不知道訊號被捕獲時進程正在執行什麼內容。如果當進程正在使用 malloc 在它的堆上分配額外的記憶體時,您通過訊號處理器調用 malloc,那會怎樣?或者,調用了正在處理全域資料結構的某個函數,而在訊號處理器中又調用了同一個函數。如果是調用 malloc,則進程會被嚴重破壞,因為 malloc 通常會為所有它所分配的地區維持一個鏈表,而它又可能正在修改那個鏈表。
甚至可以在需要多個指令的 C 操作符開始和結束之間發送中斷。在程式員看來,指令可能似乎是原子的(也就是說,不能被分割為更小的操作),但它可能實際上需要不止一個處理器指令才能完成操作。例如,看這段 C 代碼:
在 x86 處理器上,那個語句可能會被編譯為:
mov ax,[temp]inc axmov [temp],ax
|
這顯然不是一個原子操作。
這個例子展示了在修改某個變數的過程中運行訊號處理器可能會發生什麼事情:
清單 1. 在修改某個變數的同時運行訊號處理器
#include <signal.h>#include <stdio.h>struct two_int { int a, b; } data;void signal_handler(int signum){ printf ("%d, %d\n", data.a, data.b); alarm (1);}int main (void){ static struct two_int zeros = { 0, 0 }, ones = { 1, 1 }; signal (SIGALRM, signal_handler); data = zeros; alarm (1);while (1) {data = zeros; data = ones;}}
|
這個程式向 data 填充 0,1,0,1,一直交替進行。同時,alarm 訊號處理器每一秒列印一次當前內容(在處理器中調用 printf 是安全的,當訊號發生時它確實沒有在處理器外部被調用)。您預期這個程式會有怎樣的輸出?它應該列印 0,0 或者 1,1。但是實際的輸出如下所示:
0, 01, 1(Skipping some output...)0, 11, 11, 01, 0...
|
在大部分機器上,在 data 中儲存一個新值都需要若干個指令,每次儲存一個字。如果在這些指令期間發出訊號,則處理器可能發現 data.a 為 0 而 data.b 為 1,或者反之。另一方面,如果我們運行代碼的機器能夠在一個不可中斷的指令中儲存一個對象的值,那麼處理器將永遠列印 0,0 或 1,1。
使用訊號的另一個新增的困難是,只通過運行測試案例不能夠確保代碼沒有訊號 bug。這一困難的原因在於訊號產生本質上非同步。
不可重新進入函數和靜態變數
假定訊號處理器使用了不可重新進入的 gethostbyname。這個函數將它的值返回到一個靜態對象中:
static struct hostent host; /* result stored here*/
|
它每次都重新使用同一個對象。在下面的例子中,如果訊號剛好是在 main 中調用 gethostbyname 期間到達,或者甚至在調用之後到達,而程式仍然在使用那個值,則它將破壞程式請求的值。
清單 2. gethostbyname 的危險用法
main(){ struct hostent *hostPtr; ... signal(SIGALRM, sig_handler); ... hostPtr = gethostbyname(hostNameOne); ...}void sig_handler(){ struct hostent *hostPtr; ... /* call to gethostbyname may clobber the value stored during the call inside the main() */ hostPtr = gethostbyname(hostNameTwo); ...}
|
不過,如果程式不使用 gethostbyname 或者任何其他在同一對象中返回資訊的函數,或者如果它每次使用時都會阻塞訊號,那麼就是安全的。
很多庫函數在固定的對象中傳回值,總是使用同一對象,它們全都會導致相同的問題。如果某個函數使用並修改了您提供的某個對象,那它可能就是不可重新進入的;如果兩個調用使用同一對象,那麼它們會相互幹擾。
當使用流(stream)進行 I/O 時會出現類似的情況。假定訊號處理器使用 fprintf 列印一條訊息,而當訊號發出時程式正在使用同一個流進行 fprintf 調用。訊號處理器的訊息和程式的資料都會被破壞,因為兩個叫用作業了同一資料結構:流本身。
如果使用第三方程式庫,事情會變得更為複雜,因為您永遠不知道哪部分程式庫是可重新進入的,哪部分是不可重新進入的。對標準程式庫而言,有很多程式庫函數在固定的對象中傳回值,總是重複使用同一對象,這就使得那些函數不可重新進入。
近來很多供應商已經開始提供標準 C 程式庫的可重新進入版本,這是一個好訊息。對於任何給定程式庫,您都應該通讀它所提供的文檔,以瞭解其原型和標準庫函數的用法是否有所變化。
確保可重新進入性的經驗
理解這五條最好的經驗將協助您保持程式的可重新進入性。
經驗 1
返回指向待用資料的指標可能會導致函數不可重新進入。例如,將字串轉換為大寫的 strToUpper 函數可能被實現如下:
清單 3. strToUpper 的不可重新進入版本
char *strToUpper(char *str){ /*Returning pointer to static data makes it non-reentrant */ static char buffer[STRING_SIZE_LIMIT]; int index; for (index = 0; str[index]; index++) buffer[index] = toupper(str[index]); buffer[index] = '\0'; return buffer;}
|
通過修改函數的原型,您可以實現這個函數的可重新進入版本。下面的清單為輸出準備了儲存空間:
清單 4. strToUpper 的可重新進入版本
char *strToUpper_r(char *in_str, char *out_str){ int index; for (index = 0; in_str[index] != '\0'; index++) out_str[index] = toupper(in_str[index]); out_str[index] = '\0'; return out_str;}
|
由進行調用的函數準備輸出儲存空間確保了函數的可重新進入性。注意,這裡遵循了標準慣例,通過向函數名添加“_r”尾碼來命名可重新進入函數。
經驗 2
記憶資料的狀態會使函數不可重新進入。不同的線程可能會先後調用那個函數,並且修改那些資料時不會通知其他正在使用此資料的線程。如果函數需要在一系列調用期間維持某些資料的狀態,比如工作緩衝或指標,那麼調用者應該提供此資料。
在下面的例子中,函數返回某個字串的連續小寫字母。字串只是在第一次調用時給出,如 strtok 子常式。當搜尋到字串末尾時,函數返回 \0。函數可能如下實現:
清單 5. getLowercaseChar 的不可重新進入版本
char getLowercaseChar(char *str){ static char *buffer; static int index; char c = '\0'; /* stores the working string on first call only */ if (string != NULL) { buffer = str; index = 0; } /* searches a lowercase character */ while(c=buff[index]){ if(islower(c)) { index++; break; } index++; } return c;}
|
這個函數是不可重新進入的,因為它儲存變數的狀態。為了讓它可重新進入,待用資料,即 index,需要由調用者來維護。此函數的可重新進入版本可能類似如下實現:
清單 6. getLowercaseChar 的可重新進入版本
char getLowercaseChar_r(char *str, int *pIndex){ char c = '\0'; /* no initialization - the caller should have done it */ /* searches a lowercase character */ while(c=buff[*pIndex]){ if(islower(c)) { (*pIndex)++; break; } (*pIndex)++; } return c;}
|
經驗 3
在大部分系統中,malloc 和 free 都不是可重新進入的,因為它們使用待用資料結構來記錄哪些記憶體塊是閒置。實際上,任何分配或釋放記憶體的庫函數都是不可重新進入的。這也包括分配空間儲存結果的函數。
避免在處理器分配記憶體的最好方法是,為訊號處理器預先分配要使用的記憶體。避免在處理器中釋放記憶體的最好方法是,標記或記錄將要釋放的對象,讓程式不間斷地檢查是否有等待被釋放的記憶體。不過這必須要小心進行,因為將一個對象添加到一個鏈並不是原子操作,如果它被另一個做同樣動作的訊號處理器打斷,那麼就會“丟失”一個對象。不過,如果您知道當訊號可能到達時,程式不可能使用處理器那個時刻所使用的流,那麼就是安全的。如果程式使用的是某些其他流,那麼也不會有任何問題。
經驗 4
為了編寫沒有 bug 的代碼,要特別小心處理進程範圍內的全域變數,如 errno 和 h_errno。考慮下面的代碼:
清單 7. errno 的危險用法
if (close(fd) < 0) { fprintf(stderr, "Error in close, errno: %d", errno); exit(1);}
|
假定訊號在 close 系統調用設定 errno 變數到其返回之前這一極小的時間片段內產生。這個產生的訊號可能會改變 errno 的值,程式的行為會無法預計。
如下,在訊號處理器內儲存和恢複 errno 的值,可以解決這一問題:
清單 8. 儲存和恢複 errno 的值
void signalHandler(int signo){ int errno_saved; /* Save the error no. */ errno_saved = errno; /* Let the signal handler complete its job */ ... ... /* Restore the errno*/ errno = errno_saved;}
|
經驗 5
如果底層的函數處於關鍵區段,並且產生並處理訊號,那麼這可能會導致函數不可重新進入。通過使用訊號設定和訊號掩碼,代碼的關鍵地區可以被保護起來不受一組特定訊號的影響,如下:
- 儲存當前訊號設定。
- 用不必要的訊號屏蔽訊號設定。
- 使代碼的關鍵區段完成其工作。
- 最後,重設訊號設定。
下面是此方法的概述:
清單 9. 使用訊號設定和訊號掩碼
sigset_t newmask, oldmask, zeromask;.../* Register the signal handler */signal(SIGALRM, sig_handler);/* Initialize the signal sets */sigemtyset(&newmask); sigemtyset(&zeromask);/* Add the signal to the set */sigaddset(&newmask, SIGALRM);/* Block SIGALRM and save current signal mask in set variable 'oldmask'*/sigprocmask(SIG_BLOCK, &newmask, &oldmask);/* The protected code goes here......*//* Now allow all signals and pause */sigsuspend(&zeromask);/* Resume to the original signal mask */sigprocmask(SIG_SETMASK, &oldmask, NULL);/* Continue with other parts of the code */
|
忽略 sigsuspend(&zeromask); 可能會引發問題。從消除訊號阻塞到進程執行下一個指令之間,必然會有刻度間隙,任何在此時間視窗發生的訊號都會丟掉。函數調用 sigsuspend 通過重設訊號掩碼並使進程休眠一個單一的原子操作來解決這一問題。如果您能確保在此時間視窗中產生的訊號不會有任何負面影響,那麼您可以忽略 sigsuspend 並直接重新設定訊號。
在編譯器層次處理可重用性
我將提出一個在編譯器層次處理可重新進入函數的模型。可以為進階語言引入一個新的關鍵字: reentrant,函數可以被指定一個 reentrant 標識符,以此確保函數可重新進入,比如:
此指示符告知編譯器要專門處理那個特殊的函數。編譯器可以將這個指示符儲存在它的符號表中,並在中間代碼產生階段使用這個指示符。為達到此目的,編譯器的前端設計需要有一些改變。此可重新進入指示符遵循這些準則:
- 不為連續的調用持有待用資料。
- 通過製作全域資料的本地拷貝來保護全域資料。
- 絕對不調用不可重新進入的函數。
- 不返回對待用資料的引用,所有資料都由函數的調用者提供。
準則 1 可以通過類型檢查得到保證,如果在函數中有任何靜態儲存聲明,則拋出錯誤訊息。這可以在編譯的文法分析階段完成。
準則 2,全域資料的保護可以通過兩種方式得到保證。基本的方法是,如果函數修改全域資料,則拋出一個錯誤訊息。一種更為複雜的技術是以全域資料不被破壞的方式產生中間代碼。可以在編譯器層實作類別似於前面經驗 4 的方法。在進入函數時,編譯器可以使用編譯器產生的臨時名稱儲存將要被操作的全域資料,然後在退出函數時恢複那些資料。使用編譯器產生的臨時名稱儲存資料對編譯器來說是常用的方法。
確保準則 3 得到滿足,要求編譯器預Crowdsourced Security Testing道所有可重新進入函數,包括應用程式所使用的程式庫。這些關於函數的附加資訊可以儲存在符號表中。
最後,準則 4 已經得到了準則 2 的保證。如果函數沒有待用資料,那麼也就不存在返回待用資料的引用的問題。
提出的這個模型將簡化程式員遵循可重新進入函數準則的工作,而且使用此模型可以預防代碼出現無意的可重新進入性 bug。