天天看點

Linux 可重入-異步信号安全和線程安全(架構師篇)

一、可重入函數

基本定義:

  • 重入:同一個函數被不同的執行流調用,目前一個流程還沒有執行完,就有其他的程序已經再次調用(執行流之間的互相嵌套執行);
  • 可重入:多個執行流反複執行一個代碼,其結果不會發生改變,通常通路的都是各自的私有棧資源;
  • 不可重入:多個執行流反複執行一段代碼時,其結果會發生改變;
  • 可重入函數:當一個執行流因為異常或者被核心切換而中斷正在執行的函數而轉為另外一個執行流時,當後者的執行流對同一個函數的操作并不影響前一個執行流恢複後執行函數産生的結果;

當一個被捕獲的信号被一個程序處理時,程序執行的普通的指令序列會被一個信号處理器暫時地中斷。它首先執行該信号處理程式中的指令。如果從信号處理程式傳回(例如沒有調用exit或longjmp),則繼續執行在捕獲到信号時程序正在執行的正常指令序列(這和當一個硬體中斷發生時所發生的事情相似)。但是在信号處理器裡,我們并不知道當信号被捕獲時程序正在執行哪裡的代碼。

如果程序正使用malloc在它的堆上配置設定額外的記憶體,而此時由于捕捉到信号而插入執行該信号處理程式,其中又調用了malloc,這會發生什麼呢?或者,如果程序正調用一個把結果存儲在一個靜态區域裡的函數到一半,比如 getpwnam,而我們在信号處理器裡調用相同的函數,又會發生什麼呢?在malloc的例子裡,程序可能會遭到嚴重破壞,因為malloc通常維護它 所有配置設定過的區域的連結清單,而插入執行信号處理程式時,程序可能正在更改此連結表。

在getpwnam的例子裡,傳回給普通調用者的資訊可能被傳回給信号處理器的資訊覆寫。

SUS規定了必須保證是可以再入的函數。

下表列出了這些再入函數:

Linux 可重入-異步信号安全和線程安全(架構師篇)

一個可重入的函數簡單來說就是可以被中斷的函數,也就是說,可以在這個函數執行的任何時刻中斷它,轉入OS 排程下去執行另外一段代碼,而傳回控制時不會出現什麼錯誤。可重入(reentrant)函數可以由多于一個任務并發使用,而不必擔心資料錯誤。相反, 不可重入(non-reentrant)函數不能由超過一個任務所共享,除非能確定函數的互斥 (或者使用信号量,或者在代碼的關鍵部分禁用中斷)。

可重入函數可以在任意時刻被中斷, 稍後再繼續運作,不會丢失資料。可重入函數要麼使用本地變量,要麼在使用全局變量時 保護自己的資料。

信号安全,其實也就是異步信号安全,是說線程在信号處理函數當中,不管以任何方式調用你的這個函數如果不死鎖不修改資料,那就是信号安全的。是以,我認為可重入與異步信号安全是一個概念 。

可重入函數滿足條件:

  • (1)不使用全局變量或靜态變量;
  • (2)不使用用malloc或者new開辟出的空間;
  • (3)不調用不可重入函數;
  • (4)不傳回靜态或全局資料,所有資料都有函數的調用者提供;
  • (5)使用本地資料,或者通過制作全局資料的本地拷貝來保護全局資料;

不可重入函數符合以下條件之一

  • (1)調用了malloc/free函數,因為malloc函數是用全局連結清單來管理堆的。
  • (2)調用了标準I/O庫函數,标準I/O庫的很多實作都以不可重入的方式使用全局資料結構。
  • (3)可重入體内使用了靜态的資料結構。

可重入函數分類

(1)顯式可重入函數:如果所有函數的參數都是傳值傳遞的(沒有指針),并且所有的資料引用都是本地的自動棧變量(也就是說沒有引用靜态或全局變量),那麼函數就是顯示可重入的,也就是說不管如何調用,我們都可斷言它是可重入的。

(2)隐式可重入函數:可重入函數中的一些參數是引用傳遞(使用了指針),也就是說,在調用線程小心地傳遞指向非共享資料的指針時,它才是可重入的。

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

代碼示範:

#include<stdio.h>
#include<signal.h>
 
int value=0;
 
void fun(){
        int i=0;
        while(i++<5){
                value++;
                printf("value is %d\n",value);
                sleep(1);
        }
}
int main()
{
        signal(2,fun);
        fun();
        printf("the value is %d\n",value);
        return 0;
}
           
Linux 可重入-異步信号安全和線程安全(架構師篇)

二、線程安全

基本定義:

  • 線程安全:簡單來說線程安全就是多個線程并發同一段代碼時,不會出現不同的結果,我們就可以說該線程是安全的;
  • 線程不安全:說完了線程安全,線程不安全的問題就很好解釋,如果多線程并發執行時會産生不同的結果,則該線程就是不安全的。
  • 線程安全産生的原因:大多是因為對全局變量和靜态變量的操作。
  • 線程安全:一個函數被稱為線程安全的,當且僅當被多個并發線程反複的調用時,它會一直産生正确的結果。

    有一類重要的線程安全函數,叫做可重入函數,其特點在于它們具有一種屬性:當它們被多個線程調用時,不會引用任何共享的資料。

盡管線程安全和可重入有時會( 不正确的 )被用做同義詞,但是它們之間還是有清晰的技術差别的。可重入函數是線程安全函數的一個真子集。

常見的線程不安全的函數:

  • (1)不保護共享變量的函數
  • (2)函數狀态随着被調用,狀态發生變化的函數
  • (3)傳回指向靜态變量指針的函數
  • (4)調用線程不安全函數的函數

常見的線程安全的情況:

  • (1)每個線程對全局變量或者靜态變量隻有讀取的權限,而沒有寫入的權限,一般來說這些線程是安全的;
  • (2)類或者接口對于線程來說都是原子操作;
  • (3)多個線程之間的切換不會導緻該接口的執行結果存在二義性;

代碼示範:

#include<stdio.h>
#include<pthread.h>
 
int value=0;
 
void* func(void* arg){
        int i=0;
        while(i<10000){
                int tmp=value;
                value=i;
                printf("value is %d\n",value);
                value=tmp+1;
                i++;
        }
}
int main()
{
        pthread_t id1,id2;
        pthread_create(&id1,NULL,func,NULL);
        pthread_create(&id2,NULL,func,NULL);
        pthread_join(id1,NULL);
        pthread_join(id2,NULL);
        printf("value is %d\n",value);
        return 0;
}
           

三、可重入與線程安全的差別及聯系

可重入函數:重入即表示重複進入,首先它意味着這個函數可以被中斷,其次意味着它除了使用自己棧上的變量以外不依賴于任何環境(包括static ),這樣的函數就是purecode (純代碼)可重入,可以允許有該函數的多個副本在運作,由于它們使用的是分離的棧,是以不會互相幹擾。

可重入函數是線程安全函數,但是反過來,線程安全函數未必是可重入函數。

實際上,可重入函數很少,APUE 10.6 節中描述了Single UNIX Specification 說明的可重入的函數,隻有115 個;APUE 12.5 節中描述了POSIX.1 中不能保證線程安全的函數,隻有89 個。

信号就像硬體中斷一樣,會打斷正在執行的指令序列。信号處理函數無法判斷捕獲到信号的時候,程序在何處運作。如果信号處理函數中的操作與打斷的函數的操作相同,而且這個操作中有靜态資料結構等,當信号處理函數傳回的時候(當然這裡讨論的是信号處理函數可以傳回),恢複原先的執行序列,可能會導緻信号處理函數中的操作覆寫了之前正常操作中的資料。

差別:

  • (1)可重入函數是線程安全函數的一種,其特點在于它們被多個線程調用時,不會引用任何共享資料。
  • (2)線程安全是在多個線程情況下引發的,而可重入函數可以在隻有一個線程的情況下來說。
  • (3)線程安全不一定是可重入的,而可重入函數則一定是線程安全的。
  • (4)如果一個函數中有全局變量,那麼這個函數既不是線程安全也不是可重入的。
  • (5)如果将對臨界資源的通路加上鎖,則這個函數是線程安全的,但如果這個重入函數若鎖還未釋放則會産生死鎖,是以是不可重入的。
  • (6)線程安全函數能夠使不同的線程通路同一塊位址空間,而可重入函數要求不同的執行流對資料的操作互不影響使結果是相同的。

四、不可重入的幾種情況

使用靜态資料結構,比如getpwnam,getpwuid:如果信号發生時正在執行getpwnam,信号處理程式中執行getpwnam可能覆寫原來getpwnam擷取的舊值:

**調用malloc或free:**如果信号發生時正在malloc(修改堆上存儲空間的連結表),信号處理程式又調用malloc,會破壞核心的資料結構使用标準IO函數,因為好多标準IO的實作都使用全局資料結構,比如printf(檔案偏移是全局的)。

**函數中調用longjmp或siglongjmp:**信号發生時程式正在修改一個資料結構,處理程式傳回到另外一處,導緻資料被部分更新。

即使對于可重入函數,在信号處理函數中使用也需要注意一個問題就是errno 。一個線程中隻有一個errno 變量,信号處理函數中使用的可重入函數也有可能 會修改errno 。例如,read 函數是可重入的,但是它也有可能會修改errno 。是以,正确的做法是在信号處理函數開始,先儲存errno ;在信号處 理函數退出的時候,再恢複errno 。

例如,程式正在調用printf 輸出,但是在調用printf 時,出現了信号,對應的信号處理函數也有printf 語句,就會導緻兩個printf 的輸出混雜在一起。

如果是給printf 加鎖的話,同樣是上面的情況就會導緻死鎖。對于這種情況,采用的方法一般是在特定的區域屏蔽一定的信号。

屏蔽信号的方法:

signal(SIGPIPE, SIG_IGN); // 忽略一些信号
sigprocmask();// sigprocmask 隻為單線程定義的
pthread_sigmask(); // pthread_sigmasks 可以在多線程中使用
           

現在看來信号異步安全和可重入的限制似乎是一樣的,是以這裡把它們等同看待;

線程安全:如果一個函數在同一時刻可以被多個線程安全的調用,就稱該函數是線程安全的。Malloc 函數是線程安全的。

不需要共享時,請為每個線程提供一個專用的資料副本。如果共享非常重要,則提供顯式同步,以確定程式以确定的方式操作。通過将過程包含在語句中來鎖定和解除鎖定互斥,可以使不安全過程變成線程安全過程,而且可以進行串行化。

很多函數并不是線程安全的,因為他們傳回的資料是存放在靜态的記憶體緩沖區中的。通過修改接口,由調用者自行提供緩沖區就可以使這些函數變為線程安全的。

作業系統實作支援線程安全函數的時候,會對POSIX.1 中的一些非線程安全的函數提供一些可替換的線程安全版本。

例如,gethostbyname() 是線程不安全的,在Linux 中提供了gethostbyname_r() 的線程安全實作。

函數名字後面加上 _r ,以表明這個版本是可重入的(對于線程可重入,也就是說是線程安全的,但并不是說對于信号處理函數也是可重入的,或者是異步信号安全的)。

多線程程式中常見的疏忽性問題:

  • 将指針作為新線程的參數傳遞給調用方棧。
  • 在沒有同步機制保護的情況下通路全局記憶體的共享可更改狀态。
  • 兩個線程嘗試輪流擷取對同一對全局資源的權限時導緻死鎖。其中一個線程控制第一種資源,另一個線程控制第二種資源。其中一個線程放棄之前,任何一個線程都無法繼續操作。
  • 嘗試重新擷取已持有的鎖(遞歸死鎖)。
  • 在同步保護中建立隐藏的間隔。如果受保護的代碼段包含的函數釋放了同步機制,而又在傳回調用方之前重新擷取了該同步機制,則将在保護中出現此間隔。結果具有誤導性。對于調用方,表面上看全局資料已受到保護,而實際上未受到保護。
  • 将UNIX 信号與線程混合時,使用sigwait(2) 模型來處理異步信号。
  • 調用setjmp(3C) 和longjmp(3C) ,然後長時間跳躍,而不釋放互斥鎖。
  • 從對*_cond_wait() 或 *_cond_timedwait() 的調用中傳回後無法重新評估條件。
Linux 可重入-異步信号安全和線程安全(架構師篇)

學習資料視訊免費分享看這裡,免費學習。

五、總結

  • 判斷一個函數是不是可重入函數,在于判斷其能否可以被打斷,打斷後恢複運作能夠得到正确的結果。(打斷執行的指令序列并不改變函數的資料)。
  • 判斷一個函數是不是線程安全的,在于判斷其能否在多個線程同時執行其指令序列的時候,保證每個線程都能夠得到正确的結果。
  • 如果一個函數對多個線程來說是可重入的,則說這個函數是線程安全的,但這并不能說明對信号處理程式來說該函數也是可重入的。
  • 如果函數對異步信号處理程式的重入是安全的,那 麼就可以說函數是” 異步-信号安全 ” 的。

可重入與線程安全是兩個獨立的概念, 都與函數處理資源的方式有關。

首先,可重入和線程安全是兩個并不等同的概念,一個函數可以是可重入的,也可以是線程安全的,可以兩者均滿足,可以兩者皆不滿足( 該描述嚴格的說存在漏洞,參見第二條) 。

其次,從集合和邏輯的角度看,可重入是線程安全的子集,可重入是線程安全的充分非必要條件。可重入的函數一定是線程安全的,然過來則不成立。

第三,POSIX 中對可重入和線程安全這兩個概念的定義:

Reentrant Function :A function whose effect, when called by two or
more threads,is guaranteed to be as if the threads each executed
thefunction one after another in an undefined order, even ifthe
actual execution is interleaved.

Thread-Safe Function :A function that may be safely invoked
concurrently by multiple threads.

Async-Signal-Safe Function :A function that may be invoked, without
restriction fromsignal-catching functions. No function is
async-signal -safe unless explicitly described as such
           

以上三者的關系為:可重入函數 必然 是 線程安全函數 和 異步信号安全函數;線程安全函數不一定是可重入函數。

可重入與線程安全的差別展現在能否在signal 處理函數中被調用的問題上, 可重入函數在signal 處理函數中可以被安全調用,是以同時也是 Async-Signal-Safe Function ;而線程安全函數不保證可以在signal 處理函數中被安全調用,如果通過設定信号阻塞集合等方法保證一個非可重入函數不被信号中斷,那麼它也是Async-Signal-Safe Function。

值得一提的是POSIX 1003.1 的 System Interface 預設是 Thread-Safe 的,但不是Async-Signal-Safe 的。Async-Signal-Safe 的需要明确表示,比如fork () 和signal() 。

一個非可重入函數通常( 盡管不是所有情況下) 由它的外部接口和使用方法即可進行判斷。例如:strtok() 是非可重入的,因為它在内部存儲了被标記分割的字元串;ctime() 函數也是非可重入的,它傳回一個指向靜态資料的指針,而該靜态資料在每次調用中都被覆寫重寫。

一個線程安全的函數通過加鎖的方式來實作多線程對共享資料的安全通路。線程安全這個概念,隻與函數的内部實作有關,而不影響函數的外部接口。在 C 語言中,局部變量是在棧上配置設定的。是以,任何未使用靜态資料或其他共享資源的函數都是線程安全的。

目前的 AIX 版本中,以下函數庫是線程安全的:

  • C 标準函數庫
  • 與BSD 相容的函數庫

使用全局變量( 的函數) 是非線程安全的。這樣的資訊應該以線程為機關進行存儲,這樣對資料的通路就可以串行化。一個線程可能會讀取由另外一個線程生成的錯誤代碼。在AIX 中,每個線程有獨立的errno 變量。

最後讓我們來構想一個線程安全但不可重入的函數:

假設函數func() 在執行過程中需要通路某個共享資源,是以為了實作線程安全,在使用該資源前加鎖,在不需要資源解鎖。

假設該函數在某次執行過程中,在已經獲得資源鎖之後,有異步信号發生,程式的執行流轉交給對應的信号處理函數;再假設在該信号處理函數中也需要調用函數 func() ,那麼func() 在這次執行中仍會在通路共享資源前試圖獲得資源鎖,然而我們知道前一個func() 執行個體已然獲得該鎖,是以信号處理函數阻塞——另一方面,信号處理函數結束前被信号中斷的線程是無法恢複執行的,當然也沒有釋放資源的機會,這樣就出現了線程和信号處理函數之間的死鎖局面。

是以,func() 盡管通過加鎖的方式能保證線程安全,但是由于函數體對共享資源的通路,是以是非可重入。

繼續閱讀