在早期的編程中,不可重入性對程序員并不構成威脅;函數不會有并發訪問,也沒有中斷。在很多較老的 C 語言實現中,函數被認為是在單線程進程的環境中運行。
不過,現在,并發編程已普遍使用,您需要意識到這個缺陷。本文描述了在并行和并發程序設計中函數的不可重入性導致的一些潛在問題。信號的生成和處理尤其增加了額外的復雜性。由于信號在本質上是異步的,所以難以找出當信號處理函數 觸發某個不可重入函數時導致的 bug。
本文:
什么是可重入性?
可重入()函數可以由多于一個任務并發使用,而不必擔心數據錯誤。相反,不可重入(non-)函數不能由超過一個任務所共享,除非能確保函數的互斥 (或者使用信號量,或者在代碼的關鍵部分禁用中斷)。可重入函數可以在任意時刻被中斷, 稍后再繼續運行,不會丟失數據。可重入函數要么使用本地變量,要么在使用全局變量時 保護自己的數據。
可重入函數:
不要混淆可重入與線程安全。在程序員看來,這是兩個獨立的概念:函數可以是可重入的,是線程安全的,或者 二者皆是,或者二者皆非。不可重入的函數不能由多個線程使用。另外,或許不可能讓某個 不可重入的函數是線程安全的。
IEEE Std 1003.1 列出了 118 個可重入的 UNIX? 函數,在此沒有給出副本。參見中指向 上此列表的鏈接。
出于以下任意某個原因,其余函數是不可重入的:
信號和不可重入函數
信號(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 代碼:
temp += 1;
在 x86 處理器上,那個語句可能會被編譯為:
mov ax,[temp] inc ax mov [temp],ax
這顯然不是一個原子操作。
這個例子展示了在修改某個變量的過程中運行信號處理器可能會發生什么事情:
清單 1. 在修改某個變量的同時運行信號處理器
#include#include 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, 0 1, 1 (Skipping some output...) 0, 1 1, 1 1, 0 1, 0 ...
在大部分機器上,在data中存儲一個新值都需要若干個指令,每次存儲一個字。 如果在這些指令期間發出信號,則處理器可能發現data.a為 0 而data.b為 1,或者反之。另一方面,如果我們運行代碼的機器能夠在一個 不可中斷的指令中存儲一個對象的值,那么處理器將永遠打印 0,0 或 1,1。
使用信號的另一個新增的困難是,只通過運行測試用例不能夠確保代碼沒有信號 bug。這一困難的原因在于 信號生成本質上異步的。
不可重入函數和靜態變量
假定信號處理器使用了不可重入的。這個函數 將它的值返回到一個靜態對象中:
static struct hostent host; /* result stored here*/
它每次都重新使用同一個對象。在下面的例子中,如果信號剛好是在main中調用期間到達,或者甚至在調用之后到達,而程序仍然在使用那個值,則 它將破壞程序請求的值。
清單 2. 的危險用法
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); ... }
不過,如果程序不使用或者任何其他在同一對象中返回信息 的函數,或者如果它每次使用時都會阻塞信號,那么就是安全的。
很多庫函數在固定的對象中返回值,總是使用同一對象,它們全都會導致相同的問題。如果某個函數使用并修改了 您提供的某個對象,那它可能就是不可重入的;如果兩個調用使用同一對象,那么它們會相互干擾。
當使用流(stream)進行 I/O 時會出現類似的情況。假定信號處理器使用fprintf打印一條消息,而當信號發出時程序正在使用同一個流進行fprintf調用。 信號處理器的消息和程序的數據都會被破壞,因為兩個調用操作了同一數據結構:流本身。
如果使用第三方程序庫,事情會變得更為復雜,因為您永遠不知道哪部分程序庫是可重入的,哪部分是不可重入的。 對標準程序庫而言,有很多程序庫函數在固定的對象中返回值,總是重復使用同一對象,這就使得那些函數 不可重入。
近來很多提供商已經開始提供標準 C 程序庫的可重入版本,這是一個好消息。對于任何給定程序庫,您都應該通讀它所提供 的文檔,以了解其原型和標準庫函數的用法是否有所變化。
確保可重入性的經驗
理解這五條最好的經驗將幫助您保持程序的可重入性。
經驗 1
返回指向靜態數據的指針可能會導致函數不可重入。例如,將字符串轉換為大寫的函數可能被實現如下:
清單 3. 的不可重入版本
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. 的可重入版本
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. 的不可重入版本
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. 的可重入版本
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 */
忽略(&);可能會引發問題。從消除信號阻塞到進程執行下一個 指令之間,必然會有時鐘周期間隙,任何在此時間窗口發生的信號都會丟掉。函數調用通過重置信號掩碼并使進程休眠一個單一的原子操作來解決這一問題。如果您能確保在此時間窗口中生成的信號不會有任何 負面影響,那么您可以忽略并直接重新設置信號。
在編譯器層次處理可重用性
我將提出一個在編譯器層次處理可重入函數的模型。可以為高級語言引入一個新的關鍵字:,函數可以被指定一個標識符,以此確保函數可重入,比如:
reentrant int foo();
此指示符告知編譯器要專門處理那個特殊的函數。編譯器可以將這個指示符存儲在它的符號表中,并在中間代碼生成階段 使用這個指示符。為達到此目的,編譯器的前端設計需要有一些改變。此可重入指示符遵循這些準則:
不為連續的調用持有靜態數據。 通過制作全局數據的本地拷貝來保護全局數據。 絕對不調用不可重入的函數。 不返回對靜態數據的引用,所有數據都由函數的調用者提供。
準則 1 可以通過類型檢查得到保證,如果在函數中有任何靜態存儲聲明,則拋出錯誤消息。這可以在編譯的語法分析 階段完成。
準則 2,全局數據的保護可以通過兩種方式得到保證。基本的方法是,如果函數修改全局數據,則拋出一個錯誤 消息。一種更為復雜的技術是以全局數據不被破壞的方式生成中間代碼。可以在編譯器層實現類似于前面經驗 4 的方法。 在進入函數時,編譯器可以使用編譯器生成的臨時名稱存儲將要被操作的全局數據,然后在退出函數時恢復那些數據。 使用編譯器生成的臨時名稱存儲數據對編譯器來說是常用的方法。
確保準則 3 得到滿足,要求編譯器預先知道所有可重入函數,包括應用程序所使用的程序庫。這些關于函數的 附加信息可以存儲在符號表中。
最后,準則 4 已經得到了準則 2 的保證。如果函數沒有靜態數據,那么也就不存在返回靜態數據的引用的問題。
提出的這個模型將簡化程序員遵循可重入函數準則的工作,而且使用此模型可以預防代碼出現無意的可重入性 bug。
參考資料