何時(shí)如何利用可重入性避免代碼出現 bug
簡(jiǎn)介: 如果要對函數進(jìn)行并發(fā)訪(fǎng)問(wèn),不管是通過(guò)線(xiàn)程還是通過(guò)進(jìn)程,您都可能會(huì )遇到函數不可重入所導致的問(wèn)題。在本文中,通過(guò)示例 代碼了解如果可重入性不能得到保證會(huì )產(chǎn)生何種異常,尤其要注意信號。引入了五條可取的編程經(jīng)驗,并對提出的編譯器模型進(jìn)行了討論,在這個(gè)模型中,可重入性 由編譯器前端處理。
在早期的編程中,不可重入性對程序員并不構成威脅;函數不會(huì )有并發(fā)訪(fǎng)問(wèn),也沒(méi)有中斷。在很多較老的 C 語(yǔ)言實(shí)現中,函數被認為是在單線(xiàn)程進(jìn)程的環(huán)境中運行。
不過(guò),現在,并發(fā)編程已普遍使用,您需要意識到這個(gè)缺陷。本文描述了在并行和并發(fā)程序設計中函數的不可重入性導致的一些潛在問(wèn)題。信號的生成和處理尤其增 加了額外的復雜性。由于信號在本質(zhì)上是異步的,所以難以找出當信號處理函數 觸發(fā)某個(gè)不可重入函數時(shí)導致的 bug。
本文:
什么是可重入性?
可重入(reentrant)函數可以由多于一個(gè)任務(wù)并發(fā)使用,而不必擔心數據錯誤。相反, 不可重入(non-reentrant)函數不能由超過(guò)一個(gè)任務(wù)所共享,除非能確保函數的互斥 (或者使用信號量,或者在代碼的關(guān)鍵部分禁用中斷)??芍厝牒瘮悼梢栽谌我鈺r(shí)刻被中斷, 稍后再繼續運行,不會(huì )丟失數據??芍厝牒瘮狄词褂帽镜刈兞?,要么在使用全局變量時(shí) 保護自己的數據。
可重入函數:
不要混淆可重入與線(xiàn)程安全。在程序員看來(lái),這是兩個(gè)獨立的概念:函數可以是可重入的,是線(xiàn)程安全的,或者 二者皆是,或者二者皆非。不可重入的函數不能由多個(gè)線(xiàn)程使用。另外,或許不可能讓某個(gè) 不可重入的函數是線(xiàn)程安全的。
IEEE Std 1003.1 列出了 118 個(gè)可重入的 UNIX? 函數,在此沒(méi)有給出副本。參見(jiàn) 參 考資料 中指向 unix.org 上此列表的鏈接。
出于以下任意某個(gè)原因,其余函數是不可重入的:
malloc 或 free。信號和不可重入函數
信號(signal) 是軟件中斷。它使得程序員可以處理異步事件。為了向進(jìn)程發(fā)送一個(gè)信號, 內核在進(jìn)程表條目的信號域中設置一個(gè)位,對應于收到的信號的類(lèi)型。信號函數的 ANSI C 原型是:
void (*signal (int sigNum, void (*sigHandler)(int))) (int); |
或者,另一種描述形式:
typedef void sigHandler(int); |
當進(jìn)程處理所捕獲的信號時(shí),正在執行的正常指令序列就會(huì )被信號處理器臨時(shí)中斷。然后進(jìn)程繼續執行, 但現在執行的是信號處理器中的指令。如果信號處理器返回,則進(jìn)程繼續執行信號被捕獲時(shí)正在執行的 正常的指令序列。
現在,在信號處理器中您并不知道信號被捕獲時(shí)進(jìn)程正在執行什么內容。如果當進(jìn)程正在使用
malloc 在它的堆上分配額外的內存時(shí),您通過(guò)信號處理器調用
malloc,那會(huì )怎樣?或者,調用了正在處理全局數據結構的某個(gè)函數,而
在信號處理器中又調用了同一個(gè)函數。如果是調用 malloc,則進(jìn)程會(huì )
被嚴重破壞,因為 malloc 通常會(huì )為所有它所分配的區域維持一個(gè)鏈表,而它又
可能正在修改那個(gè)鏈表。
甚至可以在需要多個(gè)指令的 C 操作符開(kāi)始和結束之間發(fā)送中斷。在程序員看來(lái),指令可能似乎是原子的 (也就是說(shuō),不能被分割為更小的操作),但它可能實(shí)際上需要不止一個(gè)處理器指令才能完成操作。 例如,看這段 C 代碼:
temp += 1; |
在 x86 處理器上,那個(gè)語(yǔ)句可能會(huì )被編譯為:
mov ax,[temp] |
這顯然不是一個(gè)原子操作。
這個(gè)例子展示了在修改某個(gè)變量的過(guò)程中運行信號處理器可能會(huì )發(fā)生什么事情:
|
這個(gè)程序向 data 填充 0,1,0,1,一直交替進(jìn)行。同時(shí),alarm 信號
處理器每一秒打印一次當前內容(在處理器中調用 printf 是安全的,當信號發(fā)生時(shí)
它確實(shí)沒(méi)有在處理器外部被調用)。您預期這個(gè)程序會(huì )有怎樣的輸出?它應該打印 0,0 或者 1,1。但是實(shí)際的輸出
如下所示:
0, 0 |
在大部分機器上,在 data 中存儲一個(gè)新值都需要若干個(gè)指令,每次存儲一個(gè)字。
如果在這些指令期間發(fā)出信號,則處理器可能發(fā)現 data.a 為 0 而
data.b 為 1,或者反之。另一方面,如果我們運行代碼的機器能夠在一個(gè)
不可中斷的指令中存儲一個(gè)對象的值,那么處理器將永遠打印 0,0 或 1,1。
使用信號的另一個(gè)新增的困難是,只通過(guò)運行測試用例不能夠確保代碼沒(méi)有信號 bug。這一困難的原因在于 信號生成本質(zhì)上異步的。
不可重入函數和靜態(tài)變量
假定信號處理器使用了不可重入的 gethostbyname。這個(gè)函數
將它的值返回到一個(gè)靜態(tài)對象中:
static struct hostent host; /* result stored here*/ |
它每次都重新使用同一個(gè)對象。在下面的例子中,如果信號剛好是在 main 中調用
gethostbyname 期間到達,或者甚至在調用之后到達,而程序仍然在使用那個(gè)值,則
它將破壞程序請求的值。
|
不過(guò),如果程序不使用 gethostbyname 或者任何其他在同一對象中返回信息
的函數,或者如果它每次使用時(shí)都會(huì )阻塞信號,那么就是安全的。
很多庫函數在固定的對象中返回值,總是使用同一對象,它們全都會(huì )導致相同的問(wèn)題。如果某個(gè)函數使用并修改了 您提供的某個(gè)對象,那它可能就是不可重入的;如果兩個(gè)調用使用同一對象,那么它們會(huì )相互干擾。
當使用流(stream)進(jìn)行 I/O 時(shí)會(huì )出現類(lèi)似的情況。假定信號處理器使用 fprintf
打印一條消息,而當信號發(fā)出時(shí)程序正在使用同一個(gè)流進(jìn)行 fprintf 調用。
信號處理器的消息和程序的數據都會(huì )被破壞,因為兩個(gè)調用操作了同一數據結構:流本身。
如果使用第三方程序庫,事情會(huì )變得更為復雜,因為您永遠不知道哪部分程序庫是可重入的,哪部分是不可重入的。 對標準程序庫而言,有很多程序庫函數在固定的對象中返回值,總是重復使用同一對象,這就使得那些函數 不可重入。
近來(lái)很多提供商已經(jīng)開(kāi)始提供標準 C 程序庫的可重入版本,這是一個(gè)好消息。對于任何給定程序庫,您都應該通讀它所提供 的文檔,以了解其原型和標準庫函數的用法是否有所變化。
確??芍厝胄缘慕?jīng)驗
理解這五條最好的經(jīng)驗將幫助您保持程序的可重入性。
經(jīng)驗 1
返回指向靜態(tài)數據的指針可能會(huì )導致函數不可重入。例如,將字符串轉換為大寫(xiě)的
strToUpper 函數可能被實(shí)現如下:
|
通過(guò)修改函數的原型,您可以實(shí)現這個(gè)函數的可重入版本。下面的清單為輸出準備了存儲空間:
|
由進(jìn)行調用的函數準備輸出存儲空間確保了函數的可重入性。注意,這里遵循了標準慣例,通過(guò)向函數名添加“_r”后綴來(lái) 命名可重入函數。
經(jīng)驗 2
記憶數據的狀態(tài)會(huì )使函數不可重入。不同的線(xiàn)程可能會(huì )先后調用那個(gè)函數,并且修改那些數據時(shí)不會(huì )通知其他 正在使用此數據的線(xiàn)程。如果函數需要在一系列調用期間維持某些數據的狀態(tài),比如工作緩存或指針,那么 調用者應該提供此數據。
在下面的例子中,函數返回某個(gè)字符串的連續小寫(xiě)字母。字符串只是在第一次調用時(shí)給出,如
strtok 子例程。當搜索到字符串末尾時(shí),函數返回
\0。函數可能如下實(shí)現:
|
這個(gè)函數是不可重入的,因為它存儲變量的狀態(tài)。為了讓它可重入,靜態(tài)數據,即 index,
需要由調用者來(lái)維護。此函數的可重入版本可能類(lèi)似如下實(shí)現:
|
經(jīng)驗 3
在大部分系統中,malloc 和 free 都不是可重入的,
因為它們使用靜態(tài)數據結構來(lái)記錄哪些內存塊是空閑的。實(shí)際上,任何分配或釋放內存的庫函數都是不可重入的。這也包括分配空間存儲結果的函數。
避免在處理器分配內存的最好方法是,為信號處理器預先分配要使用的內存。避免在處理器中釋放內存的最好方法是, 標記或記錄將要釋放的對象,讓程序不間斷地檢查是否有等待被釋放的內存。不過(guò)這必須要小心進(jìn)行,因為將一個(gè)對象 添加到一個(gè)鏈并不是原子操作,如果它被另一個(gè)做同樣動(dòng)作的信號處理器打斷,那么就會(huì )“丟失”一個(gè)對象。不過(guò), 如果您知道當信號可能到達時(shí),程序不可能使用處理器那個(gè)時(shí)刻所使用的流,那么就是安全的。如果程序使用的是某些其他流,那么也不會(huì )有任何問(wèn)題。
經(jīng)驗 4
為了編寫(xiě)沒(méi)有 bug 的代碼,要特別小心處理進(jìn)程范圍內的全局變量,如
errno 和 h_errno。
考慮下面的代碼:
|
假定信號在 close 系統調用設置 errno 變量
到其返回之前這一極小的時(shí)間片段內生成。這個(gè)生成的信號可能會(huì )改變 errno
的值,程序的行為會(huì )無(wú)法預計。
如下,在信號處理器內保存和恢復 errno 的值,可以解決這一問(wèn)題:
|
經(jīng)驗 5
如果底層的函數處于關(guān)鍵部分,并且生成并處理信號,那么這可能會(huì )導致函數不可重入。通過(guò)使用信號設置和 信號掩碼,代碼的關(guān)鍵區域可以被保護起來(lái)不受一組特定信號的影響,如下:
下面是此方法的概述:
|
忽略 sigsuspend(&zeromask); 可能會(huì )引發(fā)問(wèn)題。從消除信號阻塞到進(jìn)程執行下一個(gè)
指令之間,必然會(huì )有時(shí)鐘周期間隙,任何在此時(shí)間窗口發(fā)生的信號都會(huì )丟掉。函數調用 sigsuspend
通過(guò)重置信號掩碼并使進(jìn)程休眠一個(gè)單一的原子操作來(lái)解決這一問(wèn)題。如果您能確保在此時(shí)間窗口中生成的信號不會(huì )有任何
負面影響,那么您可以忽略 sigsuspend 并直接重新設置信號。
在編譯器層次處理可重用性
我將提出一個(gè)在編譯器層次處理可重入函數的模型??梢詾楦呒壵Z(yǔ)言引入一個(gè)新的關(guān)鍵字:
reentrant,函數可以被指定一個(gè) reentrant
標識符,以此確保函數可重入,比如:
reentrant int foo(); |
此指示符告知編譯器要專(zhuān)門(mén)處理那個(gè)特殊的函數。編譯器可以將這個(gè)指示符存儲在它的符號表中,并在中間代碼生成階段 使用這個(gè)指示符。為達到此目的,編譯器的前端設計需要有一些改變。此可重入指示符遵循這些準則:
準則 1 可以通過(guò)類(lèi)型檢查得到保證,如果在函數中有任何靜態(tài)存儲聲明,則拋出錯誤消息。這可以在編譯的語(yǔ)法分析 階段完成。
準則 2,全局數據的保護可以通過(guò)兩種方式得到保證?;镜姆椒ㄊ?,如果函數修改全局數據,則拋出一個(gè)錯誤 消息。一種更為復雜的技術(shù)是以全局數據不被破壞的方式生成中間代碼??梢栽诰幾g器層實(shí)現類(lèi)似于前面經(jīng)驗 4 的方法。 在進(jìn)入函數時(shí),編譯器可以使用編譯器生成的臨時(shí)名稱(chēng)存儲將要被操作的全局數據,然后在退出函數時(shí)恢復那些數據。 使用編譯器生成的臨時(shí)名稱(chēng)存儲數據對編譯器來(lái)說(shuō)是常用的方法。
確保準則 3 得到滿(mǎn)足,要求編譯器預先知道所有可重入函數,包括應用程序所使用的程序庫。這些關(guān)于函數的 附加信息可以存儲在符號表中。
最后,準則 4 已經(jīng)得到了準則 2 的保證。如果函數沒(méi)有靜態(tài)數據,那么也就不存在返回靜態(tài)數據的引用的問(wèn)題。
提出的這個(gè)模型將簡(jiǎn)化程序員遵循可重入函數準則的工作,而且使用此模型可以預防代碼出現無(wú)意的可重入性 bug。
參考資料
關(guān)于作者
Dipak 為分布式文件系統(Distributed File System,DFS)提供 Level 3 支持。他的工作包括,對內存轉儲和崩潰 進(jìn)行內核級和用戶(hù)級的調試,以及修復 AIX 和 Solaris 平臺上所報告的 bug。通過(guò) dipakjha@in.ibm.com 與 Dipak 聯(lián)系。
聯(lián)系客服