天天看點

使用可重入函數進行更安全的信号處理

如果要對函數進行并發通路,不管是通過線程還是通過程序,您都可能會遇到函數不可重入所導緻的問題。在本文中,通過示例代碼了解如果可重入性不能得到保證會産生何種異常,尤其要注意信号。引入了五條可取的程式設計經驗,并對提出的編譯器模型進行了讨論,在這個模型中,可重入性由編譯器前端處理。

在早期的程式設計中,不可重入性對程式員并不構成威脅;函數不會有并發通路,也沒有中斷。在很多較老的 C 語言實作中,函數被認為是在單線程程序的環境中運作。

不過,現在,并發程式設計已普遍使用,您需要意識到這個缺陷。本文描述了在并行和并發程式設計中函數的不可重入性導緻的一些潛在問題。信号的生成和處理尤其增加了額外的複雜性。由于信号在本質上是異步的,是以難以找出當信号處理函數觸發某個不可重入函數時導緻的 bug。

本文:

定義了可重入性,并包含一個可重入函數的 POSIX 清單。

給出了示例,以說明不可重入性所導緻的問題。

指出了確定底層函數的可重入性的方法。

讨論了在編譯器層次上對可重入性的處理。

<a>什麼是可重入性?</a>

可重入(reentrant)函數可以由多于一個任務并發使用,而不必擔心資料錯誤。相反, 不可重入(non-reentrant)函數不能由超過一個任務所共享,除非能確定函數的互斥(或者使用信号量,或者在代碼的關鍵部分禁用中斷)。可重入函數可以在任意時刻被中斷,稍後再繼續運作,不會丢失資料。可重入函數要麼使用本地變量,要麼在使用全局變量時保護自己的資料。

可重入函數:

不為連續的調用持有靜态資料。

不傳回指向靜态資料的指針;所有資料都由函數的調用者提供。

使用本地資料,或者通過制作全局資料的本地拷貝來保護全局資料。

絕不調用任何不可重入函數。

不要混淆可重入與線程安全。在程式員看來,這是兩個獨立的概念:函數可以是可重入的,是線程安全的,或者二者皆是,或者二者皆非。不可重入的函數不能由多個線程使用。另外,或許不可能讓某個不可重入的函數是線程安全的。

出于以下任意某個原因,其餘函數是不可重入的:

它們調用了 <code>malloc</code> 或 <code>free</code>。

衆所周知它們使用了靜态資料結構體。

它們是标準 I/O 程式庫的一部分。

使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理

<a href="http://www.ibm.com/developerworks/cn/linux/l-reent.html#main"><b>回頁首</b></a>

<a>信号和不可重入函數</a>

信号(signal) 是軟體中斷。它使得程式員可以處理異步事件。為了向程序發送一個信号,核心在程序表條目的信号域中設定一個位,對應于收到的信号的類型。信号函數的 ANSI C 原型是:

或者,另一種描述形式:

當程序處理所捕獲的信号時,正在執行的正常指令序列就會被信号處理器臨時中斷。然後程序繼續執行,但現在執行的是信号處理器中的指令。如果信号處理器傳回,則程序繼續執行信号被捕獲時正在執行的正常的指令序列。

現在,在信号處理器中您并不知道信号被捕獲時程序正在執行什麼内容。如果當程序正在使用 <code>malloc</code> 在它的堆上配置設定額外的記憶體時,您通過信号處理器調用 <code>malloc</code>,那會怎樣?或者,調用了正在處理全局資料結構的某個函數,而在信号處理器中又調用了同一個函數。如果是調用 <code>malloc</code>,則程序會被嚴重破壞,因為 <code>malloc</code> 通常會為所有它所配置設定的區域維持一個連結清單,而它又可能正在修改那個連結清單。

甚至可以在需要多個指令的 C 操作符開始和結束之間發送中斷。在程式員看來,指令可能似乎是原子的(也就是說,不能被分割為更小的操作),但它可能實際上需要不止一個處理器指令才能完成操作。例如,看這段 C 代碼:

在 x86 處理器上,那個語句可能會被編譯為:

這顯然不是一個原子操作。

這個例子展示了在修改某個變量的過程中運作信号處理器可能會發生什麼事情:

<a><b>清單 1. 在修改某個變量的同時運作信号處理器</b></a>

這個程式向 <code>data</code> 填充 0,1,0,1,一直交替進行。同時,alarm 信号處理器每一秒列印一次目前内容(在處理器中調用 <code>printf</code> 是安全的,當信号發生時它确實沒有在處理器外部被調用)。您預期這個程式會有怎樣的輸出?它應該列印 0,0 或者 1,1。但是實際的輸出如下所示:

在大部分機器上,在 <code>data</code> 中存儲一個新值都需要若幹個指令,每次存儲一個字。如果在這些指令期間發出信号,則處理器可能發現 <code>data.a</code> 為 0 而 <code>data.b</code> 為 1,或者反之。另一方面,如果我們運作代碼的機器能夠在一個不可中斷的指令中存儲一個對象的值,那麼處理器将永遠列印 0,0 或 1,1。

使用信号的另一個新增的困難是,隻通過運作測試用例不能夠確定代碼沒有信号 bug。這一困難的原因在于信号生成本質上異步的。

使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理

<a>不可重入函數和靜态變量</a>

假定信号處理器使用了不可重入的 <code>gethostbyname</code>。這個函數将它的值傳回到一個靜态對象中:

它每次都重新使用同一個對象。在下面的例子中,如果信号剛好是在 <code>main</code> 中調用 <code>gethostbyname</code> 期間到達,或者甚至在調用之後到達,而程式仍然在使用那個值,則它将破壞程式請求的值。

<a><b>清單 2. gethostbyname 的危險用法</b></a>

不過,如果程式不使用 <code>gethostbyname</code> 或者任何其他在同一對象中傳回資訊的函數,或者如果它每次使用時都會阻塞信号,那麼就是安全的。

很多庫函數在固定的對象中傳回值,總是使用同一對象,它們全都會導緻相同的問題。如果某個函數使用并修改了您提供的某個對象,那它可能就是不可重入的;如果兩個調用使用同一對象,那麼它們會互相幹擾。

當使用流(stream)進行 I/O 時會出現類似的情況。假定信号處理器使用 <code>fprintf</code> 列印一條消息,而當信号發出時程式正在使用同一個流進行 <code>fprintf</code> 調用。信号處理器的消息和程式的資料都會被破壞,因為兩個調用操作了同一資料結構:流本身。

如果使用第三方程式庫,事情會變得更為複雜,因為您永遠不知道哪部分程式庫是可重入的,哪部分是不可重入的。對标準程式庫而言,有很多程式庫函數在固定的對象中傳回值,總是重複使用同一對象,這就使得那些函數不可重入。

近來很多提供商已經開始提供标準 C 程式庫的可重入版本,這是一個好消息。對于任何給定程式庫,您都應該通讀它所提供的文檔,以了解其原型和标準庫函數的用法是否有所變化。

使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理

<a>確定可重入性的經驗</a>

了解這五條最好的經驗将幫助您保持程式的可重入性。

<a>經驗 1</a>

傳回指向靜态資料的指針可能會導緻函數不可重入。例如,将字元串轉換為大寫的 <code>strToUpper</code> 函數可能被實作如下:

<a><b>清單 3. strToUpper 的不可重入版本</b></a>

通過修改函數的原型,您可以實作這個函數的可重入版本。下面的清單為輸出準備了存儲空間:

<a><b>清單 4. strToUpper 的可重入版本</b></a>

由進行調用的函數準備輸出存儲空間確定了函數的可重入性。注意,這裡遵循了标準慣例,通過向函數名添加“_r”字尾來命名可重入函數。

<a>經驗 2</a>

記憶資料的狀态會使函數不可重入。不同的線程可能會先後調用那個函數,并且修改那些資料時不會通知其他正在使用此資料的線程。如果函數需要在一系列調用期間維持某些資料的狀态,比如工作緩存或指針,那麼調用者應該提供此資料。

在下面的例子中,函數傳回某個字元串的連續小寫字母。字元串隻是在第一次調用時給出,如 <code>strtok</code> 子例程。當搜尋到字元串末尾時,函數傳回 <code>\0</code>。函數可能如下實作:

<a><b>清單 5. getLowercaseChar 的不可重入版本</b></a>

這個函數是不可重入的,因為它存儲變量的狀态。為了讓它可重入,靜态資料,即 <code>index</code>,需要由調用者來維護。此函數的可重入版本可能類似如下實作:

<a><b>清單 6. getLowercaseChar 的可重入版本</b></a>

<a>經驗 3</a>

在大部分系統中,<code>malloc</code> 和 <code>free</code> 都不是可重入的,因為它們使用靜态資料結構來記錄哪些記憶體塊是空閑的。實際上,任何配置設定或釋放記憶體的庫函數都是不可重入的。這也包括配置設定空間存儲結果的函數。

避免在處理器配置設定記憶體的最好方法是,為信号處理器預先配置設定要使用的記憶體。避免在處理器中釋放記憶體的最好方法是,标記或記錄将要釋放的對象,讓程式不間斷地檢查是否有等待被釋放的記憶體。不過這必須要小心進行,因為将一個對象添加到一個鍊并不是原子操作,如果它被另一個做同樣動作的信号處理器打斷,那麼就會“丢失”一個對象。不過,如果您知道當信号可能到達時,程式不可能使用處理器那個時刻所使用的流,那麼就是安全的。如果程式使用的是某些其他流,那麼也不會有任何問題。

<a>經驗 4</a>

為了編寫沒有 bug 的代碼,要特别小心處理程序範圍内的全局變量,如 <code>errno</code> 和 <code>h_errno</code>。考慮下面的代碼:

<a><b>清單 7. errno 的危險用法</b></a>

假定信号在 <code>close</code> 系統調用設定 <code>errno</code> 變量到其傳回之前這一極小的時間片段内生成。這個生成的信号可能會改變 <code>errno</code> 的值,程式的行為會無法預計。

如下,在信号處理器内儲存和恢複 <code>errno</code> 的值,可以解決這一問題:

<a><b>清單 8. 儲存和恢複 errno 的值</b></a>

<a>經驗 5</a>

如果底層的函數處于關鍵部分,并且生成并處理信号,那麼這可能會導緻函數不可重入。通過使用信号設定和信号掩碼,代碼的關鍵區域可以被保護起來不受一組特定信号的影響,如下:

儲存目前信号設定。

用不必要的信号屏蔽信号設定。

使代碼的關鍵部分完成其工作。

最後,重置信号設定。

下面是此方法的概述:

<a><b>清單 9. 使用信号設定和信号掩碼</b></a>

忽略 <code>sigsuspend(&amp;zeromask);</code> 可能會引發問題。從消除信号阻塞到程序執行下一個指令之間,必然會有時鐘周期間隙,任何在此時間視窗發生的信号都會丢掉。函數調用 <code>sigsuspend</code> 通過重置信号掩碼并使程序休眠一個單一的原子操作來解決這一問題。如果您能確定在此時間視窗中生成的信号不會有任何負面影響,那麼您可以忽略 <code>sigsuspend</code> 并直接重新設定信号。

使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理

<a>在編譯器層次處理可重用性</a>

我将提出一個在編譯器層次處理可重入函數的模型。可以為進階語言引入一個新的關鍵字: <code>reentrant</code>,函數可以被指定一個 <code>reentrant</code> 辨別符,以此確定函數可重入,比如:

此訓示符告知編譯器要專門處理那個特殊的函數。編譯器可以将這個訓示符存儲在它的符号表中,并在中間代碼生成階段使用這個訓示符。為達到此目的,編譯器的前端設計需要有一些改變。此可重入訓示符遵循這些準則:

通過制作全局資料的本地拷貝來保護全局資料。

絕對不調用不可重入的函數。

不傳回對靜态資料的引用,所有資料都由函數的調用者提供。

準則 1 可以通過類型檢查得到保證,如果在函數中有任何靜态存儲聲明,則抛出錯誤消息。這可以在編譯的文法分析階段完成。

準則 2,全局資料的保護可以通過兩種方式得到保證。基本的方法是,如果函數修改全局資料,則抛出一個錯誤消息。一種更為複雜的技術是以全局資料不被破壞的方式生成中間代碼。可以在編譯器層實作類似于前面經驗 4 的方法。在進入函數時,編譯器可以使用編譯器生成的臨時名稱存儲将要被操作的全局資料,然後在退出函數時恢複那些資料。使用編譯器生成的臨時名稱存儲資料對編譯器來說是常用的方法。

確定準則 3 得到滿足,要求編譯器預先知道所有可重入函數,包括應用程式所使用的程式庫。這些關于函數的附加資訊可以存儲在符号表中。

最後,準則 4 已經得到了準則 2 的保證。如果函數沒有靜态資料,那麼也就不存在傳回靜态資料的引用的問題。

提出的這個模型将簡化程式員遵循可重入函數準則的工作,而且使用此模型可以預防代碼出現無意的可重入性 bug。

<a>參考資料</a>

<a>關于作者</a>

使用可重入函數進行更安全的信号處理
使用可重入函數進行更安全的信号處理

繼續閱讀