Linux之信號第二談


重提信號概念

 

   前一篇中提到了信號的概念,並且對信號的產生及一些屬性有了一定的認識,這一次,要從更加深入的角度看待信號。

    之前提到過,當我的進程在接收到信號之前,就已經知道了,當我接收到某種信號之後就要發生某一項動作,換句話說,在進程內部,一定存在這某種結構,將這些信息都記錄了下來,很明顯,對於進程而言,這些信息都會保存在它的PCB當中。

    

    首先我們來認識這樣幾個概念:

信號遞達(Delivery):執行信號的處理動作;

信號未決(Pending):信號從產生到遞達之間的狀態;

阻塞(Block):被阻塞的信號被保存在未決狀態,直到解除阻塞之後,纔會執行遞達動作。只要信號阻塞就永遠不會遞達;

忽略(Ignore):忽略完全不同於阻塞,忽略是在遞達之後可選的一種動作;

    這樣的幾個概念顯得有點太過籠統,這裏截取了一張信號在PCB中的示意圖,如下:

wKioL1iuu2Ghqs0xAAGMt1He2XA889.png

  每個信號都有兩個標誌位分別表示阻塞(block)未決(pending),還有一個函數指針表示處理動作。信號產生時,內核在進程控制塊中設置該信號的未決狀態,直到信號遞達清除該標誌位。 

    Linux爲了節省內存空間,在設計的時候,使用了類似位圖的結構,只留出一個bit的大小,分別用0和1表示阻塞或者未決狀態,那麼對於上圖,我們就可以看到三張表:阻塞表,未決表,handler表。

    對於阻塞表,該位爲0,表示進程對該信號不阻塞,爲1表示對該信號阻塞;

    對於未決表,該位爲0,表示該信號沒有產生,爲1表示該信號已經發生;

    handler表就類似我們之前提到過的信號處理函數signal(),用來表示對於某一信號的處理方式。


瞭解了基本結構之後,有幾點我們需要說明:

    1、pending表和block表之間沒有任何關係。信號的產生是異步的,對於進程而言完全隨機,而阻塞狀態是該進程對某一信號所做的限制;

    2、信號的發生,對於進程而言,只是將該進程PCB中的pending表中的對應位置1,其他的操作和信號就不再有任何直接關係,這就解釋了在信號來臨之前進程就已知了某個信號對應的動作;

    3、在Linux下,由於這裏只是通過一個bit位來存儲信息,所以在信號遞達之前,信號發生多次只記一次。當然,更嚴格意義上說,常規信號是這樣的,對於實時信號(34~64號信號),在遞達之前,多次產生的信號會保存到某個隊列當中,實時信號暫時不在我們的討論範圍之內。

    4、任何一個信號,都不會是被立刻遞達,這個後面解釋。

    

    由於阻塞標誌和未決標誌都是用一個bit位來表示,因此對於Linux,引入了一個用戶類型sigset_t,兩種標誌都可以使用sigset_t數據類型來存儲,sigset_t稱爲信號集。因此就有了阻塞信號集未決信號集阻塞信號集又叫做信號屏蔽字(有沒有很熟悉的感覺)。


信號集操作函數


    信號集操作函數,顧名思義,就是對上面的幾種信號進行操作,之前我們提到的信號操作函數,實際上就是在更改這裏的pending表,因此,我們這裏提到的信號集操作函數,可以查詢和修改阻塞信號集中的數據,對於pending表中的數據,這裏只提供了查看的函數接口。具體函數聲明如下:


// 信號集操作函數
#include <signal.h>
       int sigemptyset(sigset_t *set);
               # 初始化,清零所有信號對應的bit位
       int sigfillset(sigset_t *set);
               # 對所有信號的bit位置1
       int sigaddset(sigset_t *set, int signum);
               # 將指定信號bit位置1
       int sigdelset(sigset_t *set, int signum);
               # 將指定信號bit位清零
以上四個函數,成功返回0,失敗返回-1

       int sigismember(const sigset_t *set, int signum);
               # 判斷一個信號集的有效信號中,是否包含某個信號
                # 包含返回1, 不包含返回-1


// 屏蔽信號集操作函數(寫)
          int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
                      # 讀取或更改進程中的信號屏蔽字(阻塞信號集)
                      # 成功返回0, 失敗返回-1
                      # 參數1,how有三種定義
                            SIG_BLOCK:添加對應位,mask = mask| set
                            SIG_UNBLOCK:清零對應位mask&~set
                            SIG_SETMASK:設置對於位mask=set
                      # 參數2,設置的SIG值
                      # 參數3, 輸出型參數,用來獲取修改之前的信號屏蔽字

    當我們調用sigprocmask對某些信號解除屏蔽之後,在該函數返回之前,至少有一個信號被遞達

// 未決信號集操作函數(讀)
#include <signal.h>
     int sigpending(sigset_t *set);
               # 輸出型參數,將pending列表通過set傳回
               # 成功返回0,失敗返回-1

說了這麼多,下面通過代碼做一簡單驗證。(以SIGINT信號爲例)

#include <stdio.h>
#include <signal.h>
void printfPending(sigset_t *pending)
{
   int i = 1;
   for(;i <= 31; i++)
   {
       if(sigismember(pending, i)){
           printf("1");
       }
       else{
           printf("0");
       }
   }
   printf("\n");
}
int main()
{
   sigset_t block, oblock, pending;
   sigemptyset(&block);
   sigaddset(&block, SIGINT);    // 設置block值
   
   sigprocmask(SIG_SETMASK, &block, &oblock);    // 設置信號屏蔽字
   while(1){
       sleep(1);
       sigpending(&pending);
       printfPending(&pending);    // 獲取pending值
   }
   printf("hello world\n");
   return 0;
}

    因爲SIGINT信號對應的操作是ctrl+c,但上面將SIGINT信號設置爲屏蔽狀態,因此,當我們輸入ctrl+c之後並沒有立即終止該進程,我們看到的第二爲pending值由0變爲1。如下圖:

wKioL1iuyLmCi0pfAAArBv5QCz4035.png

    接下來將代碼做一簡單調整,我們設置10秒之後,信號屏蔽字被自動清零,爲了防止ctrl+c將信號終止,所以這裏SIGINT信號執行自定義行爲,代碼如下:

#include <stdio.h>
#include <signal.h>

void printfPending(sigset_t *pending)
{
int i = 1;
for(;i <= 31; i++){
if(sigismember(pending, i)){
printf("1");
}
else{
printf("0");
}
}
printf("\n");
}

void runSig(int i)
{
printf("run SIGINT\n");
}

int main()
{
signal(SIGINT, runSig);
sigset_t block, oblock, pending;
sigemptyset(&block);
sigaddset(&block, SIGINT);

sigprocmask(SIG_SETMASK, &block, &oblock);
int count = 0;
while(1)
{
if(count == 10)
{
sigdelset(&block, SIGINT);
sigprocmask(SIG_SETMASK, &block, &oblock);
}
sleep(1);
sigpending(&pending);
printfPending(&pending);
count++;
}
printf("hello world\n");
return 0;
}


運行行結果如下:

wKiom1iuzODw9fh_AABjtYatPJ4600.png

    由於這裏已經設置了自定義SIGINT的動作,因此,即使10秒之後,ctrl+c也不會終止進程



信號捕捉


信號捕捉的過程


    關於信號捕捉,其實前面一直在說,我們把對信號的自定義行爲稱爲信號捕捉。對信號的處理有三種,忽略,默認,捕捉。

    前兩種算是比較簡單的。站在操作系統的角度,忽略信號其實要做的就是將pending中的1改爲0即可,不需要其他操作;對於默認動作,大部分的默認動作的最終結果都是終止進程,先有個簡單簡單認識,接下來看捕捉狀態下的情況,看下面這張圖:


wKioL1iu1EfRrmmFAAEOhdxZxDM565.png

①:發生了外部終端,或者遇到了陷阱、異常,這個時候,會由用戶態切換到內核態處理該異常;

②:內核處理完成異常之後,在返回用戶態執行原代碼之前,會檢查該進程的PCB中有無未處理的信號(內核會在內核態切換到用戶態的過程中檢查有無未處理的信號);

③:這時發現了存在未處理的信號,不受阻塞,而且該信號的處理動作是捕捉的,就會切換到用戶態去執行自定義的函數(因爲這個函數是用戶定義的,如果不切換用戶,由內核態直接去執行,是不安全的);

④:在執行完自定義的信號處理函數之後,會受到系統調用再次切換到內核態;

⑤:再次進行檢查,然後返回到用戶態,從上次被中斷的地方繼續向下執行。


    這就是捕捉的整個過程,一共發生了四次用戶態到內核態之間的轉化,這時候再看我們的忽略動作,當執行的第三步之後,發現該動作是忽略,於是在內核態直接將pending中的對應位清零,直接返回用戶態終端的地方繼續執行。對於默認動作,由於通常會終止進程,所以在內核態將對應位的pending值改0之後,同時銷燬PCB,直接結束進程。(這個過程還是挺重要的)


sigaction()函數


    sigaction函數可以設置和讀取與指定信號相關聯的動作,與signal函數功能類似,函數聲明與註釋如下:

#include <signal.h>
    int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
                # 成功返回0, 失敗返回-1
                # 參數1,信號編號
                # 參數2,若act非空,按照結構體中的信息修改處理動作
                # 參數3,輸出型參數,若非空,獲取原來的struct結構。
       struct sigaction {
               void     (*sa_handler)(int);
               void     (*sa_sigaction)(int, siginfo_t *, void *);
               sigset_t   sa_mask;
               int        sa_flags;
               void     (*sa_restorer)(void);
           };
               # sa_handler有三種,SIG_DFL表示默認動作;SIG_IGN表示忽略信號;爲函數指針,表示執行捕捉動作
               # sa_mask表示當正在對該信號動作時,除了當前信號被屏蔽之外,還需要屏蔽的其他信號
               # sa_flags這裏直接設置爲0即可,暫不關心
               # 其他兩個參數這裏也暫不關心


這裏給出測試代碼:

#include <stdio.h>
#include <signal.h>

void IntRun(int i)
{
	printf("my sigaction is running\n");
}

int main()
{
	struct sigaction act, oact;
	act.sa_handler = IntRun;
	sigemptyset(&act.sa_mask);
	act.sa_flags = 0;
	sigaction(SIGINT, &act, &oact);
	while(1){
		sleep(1);
		printf("hello world\n");
	}
	return 0;
}

輸出結果如圖:

wKioL1iu3hiAw3GsAAB5zswdvFQ215.png

    sigaction與signal函數功能類似,這裏只介紹用法,不在多說。


pause()函數

    

首先給出pause函數的定義

#include <unistd.h>
    int pause(void);

函數定義特別簡單,pause函數的功能是將調用進程掛起,直到有信號遞達。

        如果到達的信號是將進程終止,那麼進程直接結束,來不及返回;

        如果到達信號被忽略,則繼續掛起,無返回值;

        如果調用動作是捕捉,那麼調用信號處理函數之後,pause返回-1,同時設置errno爲EINTR(被信號中斷)。

        可見,pause函數,只有當出錯的情況下纔會有返回值,這點和exec函數類似。

接下來,讓我們寫一段小代碼,使用alarm函數和pause函數寫一個自己的sleeep函數,函數名爲mysleep。

        實現原理:利用了pause函數的特性,會將進程掛起,直到有捕獲(catch)的行爲,纔會將pause函數終止。利用alarm函數定時,鬧鐘時間到達之後,會調用自定義函數,發生捕獲行爲,導致pause函數終止,從而實現了sleep的功能。

    這裏給出了signal函數和sigaction函數版本的,兩者基本一致,不同之處在於sigaction需要設置的參數較多。代碼如下:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void run_alarm(int i)
{}

/*
// signal版本
size_t mysleep(size_t second)
{
	signal(SIGALRM, run_alarm);
	alarm(second);
	pause();
	int ret = alarm(0);
	return ret;
}
*/
// sigaction版本
size_t mysleep(size_t second)
{
	struct sigaction act, oact;
	act.sa_handler = run_alarm;
	sigemptyset(&act.sa_mask);
	act.sa_flags = 0;

	sigaction(SIGALRM, &act, &oact);
	alarm(second);
	pause();
	int ret = alarm(0);

	sigaction(SIGALRM, &oact, NULL);
	return ret;
}
int main()
{
	while(1){
		mysleep(2);
		printf("this is mysleep\n");
	}
	return 0;
}


可重入函數


    可重入函數的概念其實很好理解。有些函數,如果重入不會導致出錯或不安全的話,我們把這些函數叫做可重入函數,反之,叫做不可重入函數。

    舉個例子,當我們對一個鏈表進行插入的時候,中途收到一個信號,該信號執行自定義動作,該動作也是在該結點處插入一個新節點,就會造成下圖所示的情況,最終的2號結點並沒有被插入,這就是所說的不可重入函數

wKiom1iu7k3SIEmAAAFJmZbOtRc189.png


    問題來了,很容易可以發現,這個和線程安全有着很大的相似之處,都是由於重入導致的問題,這裏做以簡單區分。

區別:

    1、前提不同:線程安全是在多線程情況下產生的,可重入函數可以是在單線程下由信號的捕獲產生的的重入

    2、範圍不同:線程安全不一定可重入,可重入函數一定滿足線程安全

    3、對臨界資源加鎖可以實現線程安全,但依舊是不可重入的,因爲加鎖只能防止多線程的情況,單一線程的情況不一定安全。

    4、線程安全要求不同線程訪問同一塊地址空間,而可重入函數要求不同的執行流對數據的操作互不影響。


可重入函數的幾點必要條件

       1、不在函數內部使用靜態或全局數據 ;
       2、不返回靜態或全局數據,所有數據都由函數的調用者提供;
       3、使用本地數據,或者通過製作全局數據的本地拷貝來保護全局數據;
       4、不調用不可重入函數;



本文所有源代碼,以打包上傳,下載連接:

https://github.com/muhuizz/Linux/tree/master/Linux%E7%B3%BB%E7%BB%9F%E7%BC%96%E7%A8%8B/%E4%BF%A1%E5%8F%B7/code


------muhuizz整理

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章