4. 捕捉信號

4.1. 內核如何實現信號的捕捉

如果信號的處理動作是用戶自定義函數,在信號遞達時就調用這個函數,這稱為捕捉信號。由於信號處理函數的代碼是在用戶空間的,處理過程比較複雜,舉例如下:

  1. 用戶程序註冊了SIGQUIT信號的處理函數sighandler

  2. 當前正在執行main函數,這時發生中斷或異常切換到內核態。

  3. 在中斷處理完畢後要返回用戶態的main函數之前檢查到有信號SIGQUIT遞達。

  4. 內核決定返回用戶態後不是恢復main函數的上下文繼續執行,而是執行sighandler函數,sighandlermain函數使用不同的堆棧空間,它們之間不存在調用和被調用的關係,是兩個獨立的控制流程。

  5. sighandler函數返回後自動執行特殊的系統調用sigreturn再次進入內核態。

  6. 如果沒有新的信號要遞達,這次再返回用戶態就是恢復main函數的上下文繼續執行了。

圖 33.2. 信號的捕捉

信號的捕捉

上圖出自[ULK]

4.2. sigaction

#include <signal.h>

int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

sigaction函數可以讀取和修改與指定信號相關聯的處理動作。調用成功則返回0,出錯則返回-1。signo是指定信號的編號。若act指針非空,則根據act修改該信號的處理動作。若oact指針非空,則通過oact傳出該信號原來的處理動作。actoact指向sigaction結構體:

struct sigaction {
   void      (*sa_handler)(int);   /* addr of signal handler, */
                                       /* or SIG_IGN, or SIG_DFL */
   sigset_t sa_mask;               /* additional signals to block */
   int      sa_flags;              /* signal options, Figure 10.16 */

   /* alternate handler */
   void     (*sa_sigaction)(int, siginfo_t *, void *);
};

sa_handler賦值為常數SIG_IGN傳給sigaction表示忽略信號,賦值為常數SIG_DFL表示執行系統預設動作,賦值為一個函數指針表示用自定義函數捕捉信號,或者說向內核註冊了一個信號處理函數,該函數返回值為void,可以帶一個int參數,通過參數可以得知當前信號的編號,這樣就可以用同一個函數處理多種信號。顯然,這也是一個回調函數,不是被main函數調用,而是被系統所調用。

當某個信號的處理函數被調用時,內核自動將當前信號加入進程的信號屏蔽字,當信號處理函數返回時自動恢復原來的信號屏蔽字,這樣就保證了在處理某個信號時,如果這種信號再次產生,那麼它會被阻塞到當前處理結束為止。如果在調用信號處理函數時,除了當前信號被自動屏蔽之外,還希望自動屏蔽另外一些信號,則用sa_mask欄位說明這些需要額外屏蔽的信號,當信號處理函數返回時自動恢復原來的信號屏蔽字。

sa_flags欄位包含一些選項,本章的代碼都把sa_flags設為0,sa_sigaction是實時信號的處理函數,本章不詳細解釋這兩個欄位,有興趣的讀者參考[APUE2e]

4.3. pause

#include <unistd.h>

int pause(void);

pause函數使調用進程掛起直到有信號遞達。如果信號的處理動作是終止進程,則進程終止,pause函數沒有機會返回;如果信號的處理動作是忽略,則進程繼續處于掛起狀態,pause不返回;如果信號的處理動作是捕捉,則調用了信號處理函數之後pause返回-1,errno設置為EINTR,所以pause只有出錯的返回值(想想以前還學過什麼函數只有出錯返回值?)。錯誤碼EINTR表示“被信號中斷”。

下面我們用alarmpause實現sleep(3)函數,稱為mysleep

例 33.2. mysleep

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

void sig_alrm(int signo)
{
	/* nothing to do */
}

unsigned int mysleep(unsigned int nsecs)
{
	struct sigaction newact, oldact;
	unsigned int unslept;

	newact.sa_handler = sig_alrm;
	sigemptyset(&newact.sa_mask);
	newact.sa_flags = 0;
	sigaction(SIGALRM, &newact, &oldact);

	alarm(nsecs);
	pause();

	unslept = alarm(0);
	sigaction(SIGALRM, &oldact, NULL);

	return unslept;
}

int main(void)
{
	while(1){
		mysleep(2);
		printf("Two seconds passed\n");
	}
	return 0;
}

  1. main函數調用mysleep函數,後者調用sigaction註冊了SIGALRM信號的處理函數sig_alrm

  2. 調用alarm(nsecs)設定閙鐘。

  3. 調用pause等待,內核切換到別的進程運行。

  4. nsecs秒之後,閙鐘超時,內核發SIGALRM給這個進程。

  5. 從內核態返回這個進程的用戶態之前處理未決信號,發現有SIGALRM信號,其處理函數是sig_alrm

  6. 切換到用戶態執行sig_alrm函數,進入sig_alrm函數時SIGALRM信號被自動屏蔽,從sig_alrm函數返回時SIGALRM信號自動解除屏蔽。然後自動執行系統調用sigreturn再次進入內核,再返回用戶態繼續執行進程的主控制流程(main函數調用的mysleep函數)。

  7. pause函數返回-1,然後調用alarm(0)取消閙鐘,調用sigaction恢復SIGALRM信號以前的處理動作。

以下問題留給讀者思考:

1、信號處理函數sig_alrm什麼都沒幹,為什麼還要註冊它作為SIGALRM的處理函數?不註冊信號處理函數可以嗎?

2、為什麼在mysleep函數返回前要恢復SIGALRM信號原來的sigaction

3、mysleep函數的返回值表示什麼含義?什麼情況下返回非0值?。

4.4. 可重入函數

當捕捉到信號時,不論進程的主控制流程當前執行到哪兒,都會先跳到信號處理函數中執行,從信號處理函數返回後再繼續執行主控制流程。信號處理函數是一個單獨的控制流程,因為它和主控制流程是非同步的,二者不存在調用和被調用的關係,並且使用不同的堆棧空間。引入了信號處理函數使得一個進程具有多個控制流程,如果這些控制流程訪問相同的全局資源(全局變數、硬件資源等),就有可能出現衝突,如下面的例子所示。

圖 33.3. 不可重入函數

不可重入函數

main函數調用insert函數向一個鏈表head中插入節點node1,插入操作分為兩步,剛做完第一步的時候,因為硬件中斷使進程切換到內核,再次回用戶態之前檢查到有信號待處理,於是切換到sighandler函數,sighandler也調用insert函數向同一個鏈表head中插入節點node2,插入操作的兩步都做完之後從sighandler返回內核態,再次回到用戶態就從main函數調用的insert函數中繼續往下執行,先前做第一步之後被打斷,現在繼續做完第二步。結果是,main函數和sighandler先後向鏈表中插入兩個節點,而最後只有一個節點真正插入鏈表中了。

像上例這樣,insert函數被不同的控制流程調用,有可能在第一次調用還沒返回時就再次進入該函數,這稱為重入,insert函數訪問一個全局鏈表,有可能因為重入而造成錯亂,像這樣的函數稱為不可重入函數,反之,如果一個函數隻訪問自己的局部變數或參數,則稱為可重入(Reentrant)函數。想一下,為什麼兩個不同的控制流程調用同一個函數,訪問它的同一個局部變數或參數就不會造成錯亂?

如果一個函數符合以下條件之一則是不可重入的:

  • 調用了mallocfree,因為malloc也是用全局鏈表來管理堆的。

  • 調用了標準I/O庫函數。標準I/O庫的很多實現都以不可重入的方式使用全局資料結構。

SUS規定有些系統函數必須以綫程安全的方式實現,這裡就不列了,請參考[APUE2e]

4.5. sig_atomic_t類型與volatile限定符

在上面的例子中,mainsighandler都調用insert函數則有可能出現鏈表的錯亂,其根本原因在於,對全局鏈表的插入操作要分兩步完成,不是一個原子操作,假如這兩步操作必定會一起做完,中間不可能被打斷,就不會出現錯亂了。下一節綫程會講到如何保證一個代碼段以原子操作完成。

現在想一下,如果對全局數據的訪問只有一行代碼,是不是原子操作呢?比如,mainsighandler都對一個全局變數賦值,會不會出現錯亂呢?比如下面的程序:

long long a;
int main(void)
{
	a=5;
	return 0;
}

帶調試信息編譯,然後帶原始碼反彙編:

$ gcc main.c -g
$ objdump -dS a.out

其中main函數的指令中有:

	a=5;
 8048352:       c7 05 50 95 04 08 05    movl   $0x5,0x8049550
 8048359:       00 00 00 
 804835c:       c7 05 54 95 04 08 00    movl   $0x0,0x8049554
 8048363:       00 00 00

雖然C代碼只有一行,但是在32位機上對一個64位的long long變數賦值需要兩條指令完成,因此不是原子操作。同樣地,讀取這個變數到寄存器需要兩個32位寄存器才放得下,也需要兩條指令,不是原子操作。請讀者設想一種時序,mainsighandler都對這個變數a賦值,最後變數a的值發生錯亂。

如果上述程序在64位機上編譯執行,則有可能用一條指令完成賦值,因而是原子操作。如果a是32位的int變數,在32位機上賦值是原子操作,在16位機上就不是。如果在程序中需要使用一個變數,要保證對它的讀寫都是原子操作,應該採用什麼類型呢?為瞭解決這些平台相關的問題,C標準定義了一個類型sig_atomic_t,在不同平台的C語言庫中取不同的類型,例如在32位機上定義sig_atomic_tint類型。

在使用sig_atomic_t類型的變數時,還需要注意另一個問題。看如下的例子:

#include <signal.h>

sig_atomic_t a=0;
int main(void)
{
	/* register a sighandler */
	while(!a); /* wait until a changes in sighandler */
	/* do something after signal arrives */
	return 0;
}

為了簡潔,這裡只寫了一個代碼框架來說明問題。在main函數中首先要註冊某個信號的處理函數sighandler,然後在一個while死循環中等待信號發生,如果有信號遞達則執行sighandler,在sighandler中將a改為1,這樣再次回到main函數時就可以退出while循環,執行後續處理。用上面的方法編譯和反彙編這個程序,在main函數的指令中有:

	/* register a sighandler */
	while(!a); /* wait until a changes in sighandler */
 8048352:       a1 3c 95 04 08          mov    0x804953c,%eax
 8048357:       85 c0                   test   %eax,%eax
 8048359:       74 f7                   je     8048352 <main+0xe>

將全局變數a從內存讀到eax寄存器,對eaxeax做AND運算,若結果為0則跳回循環開頭,再次從內存讀變數a的值,可見這三條指令等價于C代碼的while(!a);循環。如果在編譯時加了優化選項,例如:

$ gcc main.c -O1 -g
$ objdump -dS a.out

main函數的指令中有:

 8048352:       83 3d 3c 95 04 08 00    cmpl   $0x0,0x804953c
	/* register a sighandler */
	while(!a); /* wait until a changes in sighandler */
 8048359:       74 fe                   je     8048359 <main+0x15>

第一條指令將全局變數a的內存單元直接和0比較,如果相等,則第二條指令成了一個死循環,注意,這是一個真正的死循環:即使sighandlera改為1,只要沒有影響Zero標誌位,回到main函數後仍然死在第二條指令上,因為不會再次從內存讀取變數a的值。

是編譯器優化得有錯誤嗎?不是的。設想一下,如果程序只有單一的執行流程,只要當前執行流程沒有改變a的值,a的值就沒有理由會變,不需要反覆從內存讀取,因此上面的兩條指令和while(!a);循環是等價的,並且優化之後省去了每次循環讀內存的操作,效率非常高。所以不能說編譯器做錯了,只能說編譯器無法識別程序中存在多個執行流程。之所以程序中存在多個執行流程,是因為調用了特定平台上的特定庫函數,比如sigactionpthread_create,這些不是C語言本身的規範,不歸編譯器管,程序員應該自己處理這些問題。C語言提供了volatile限定符,如果將上述變數定義為volatile sig_atomic_t a=0;那麼即使指定了優化選項,編譯器也不會優化掉對變數a內存單元的讀寫。

對於程序中存在多個執行流程訪問同一全局變數的情況,volatile限定符是必要的,此外,雖然程序只有單一的執行流程,但是變數屬於以下情況之一的,也需要volatile限定:

  • 變數的內存單元中的數據不需要寫操作就可以自己發生變化,每次讀上來的值都可能不一樣

  • 即使多次向變數的內存單元中寫數據,只寫不讀,也並不是在做無用功,而是有特殊意義的

什麼樣的內存單元會具有這樣的特性呢?肯定不是普通的內存,而是映射到內存地址空間的硬件寄存器,例如串口的接收寄存器屬於上述第一種情況,而發送寄存器屬於上述第二種情況。

sig_atomic_t類型的變數應該總是加上volatile限定符,因為要使用sig_atomic_t類型的理由也正是要加volatile限定符的理由。

4.6. 競態條件與sigsuspend函數

現在重新審視例 33.2 “mysleep”,設想這樣的時序:

  1. 註冊SIGALRM信號的處理函數。

  2. 調用alarm(nsecs)設定閙鐘。

  3. 內核調度優先順序更高的進程取代當前進程執行,並且優先順序更高的進程有很多個,每個都要執行很長時間

  4. nsecs秒鐘之後閙鐘超時了,內核發送SIGALRM信號給這個進程,處于未決狀態。

  5. 優先順序更高的進程執行完了,內核要調度回這個進程執行。SIGALRM信號遞達,執行處理函數sig_alrm之後再次進入內核。

  6. 返回這個進程的主控制流程,alarm(nsecs)返回,調用pause()掛起等待。

  7. 可是SIGALRM信號已經處理完了,還等待什麼呢?

出現這個問題的根本原因是系統運行的時序(Timing)並不像我們寫程序時所設想的那樣。雖然alarm(nsecs)緊接着的下一行就是pause(),但是無法保證pause()一定會在調用alarm(nsecs)之後的nsecs秒之內被調用。由於非同步事件在任何時候都有可能發生(這裡的非同步事件指出現更高優先順序的進程),如果我們寫程序時考慮不周密,就可能由於時序問題而導致錯誤,這叫做競態條件(Race Condition)

如何解決上述問題呢?讀者可能會想到,在調用pause之前屏蔽SIGALRM信號使它不能提前遞達就可以了。看看以下方法可行嗎?

  1. 屏蔽SIGALRM信號;

  2. alarm(nsecs);

  3. 解除對SIGALRM信號的屏蔽;

  4. pause();

從解除信號屏蔽到調用pause之間存在間隙,SIGALRM仍有可能在這個間隙遞達。要消除這個間隙,我們把解除屏蔽移到pause後面可以嗎?

  1. 屏蔽SIGALRM信號;

  2. alarm(nsecs);

  3. pause();

  4. 解除對SIGALRM信號的屏蔽;

這樣更不行了,還沒有解除屏蔽就調用pausepause根本不可能等到SIGALRM信號。要是“解除信號屏蔽”和“掛起等待信號”這兩步能合併成一個原子操作就好了,這正是sigsuspend函數的功能。sigsuspend包含了pause的掛起等待功能,同時解決了競態條件的問題,在對時序要求嚴格的場合下都應該調用sigsuspend而不是pause

#include <signal.h>

int sigsuspend(const sigset_t *sigmask);

pause一樣,sigsuspend沒有成功返回值,只有執行了一個信號處理函數之後sigsuspend才返回,返回值為-1,errno設置為EINTR

調用sigsuspend時,進程的信號屏蔽字由sigmask參數指定,可以通過指定sigmask來臨時解除對某個信號的屏蔽,然後掛起等待,當sigsuspend返回時,進程的信號屏蔽字恢復為原來的值,如果原來對該信號是屏蔽的,從sigsuspend返回後仍然是屏蔽的。

以下用sigsuspend重新實現mysleep函數:

unsigned int mysleep(unsigned int nsecs)
{
	struct sigaction    newact, oldact;
	sigset_t            newmask, oldmask, suspmask;
	unsigned int        unslept;

	/* set our handler, save previous information */
	newact.sa_handler = sig_alrm;
	sigemptyset(&newact.sa_mask);
	newact.sa_flags = 0;
	sigaction(SIGALRM, &newact, &oldact);

	/* block SIGALRM and save current signal mask */
	sigemptyset(&newmask);
	sigaddset(&newmask, SIGALRM);
	sigprocmask(SIG_BLOCK, &newmask, &oldmask);

	alarm(nsecs);

	suspmask = oldmask;
	sigdelset(&suspmask, SIGALRM);    /* make sure SIGALRM isn't blocked */
	sigsuspend(&suspmask);            /* wait for any signal to be caught */

	/* some signal has been caught,   SIGALRM is now blocked */

	unslept = alarm(0);
	sigaction(SIGALRM, &oldact, NULL);  /* reset previous action */

	/* reset signal mask, which unblocks SIGALRM */
	sigprocmask(SIG_SETMASK, &oldmask, NULL);
	return(unslept);
}

如果在調用mysleep函數時SIGALRM信號沒有屏蔽:

  1. 調用sigprocmask(SIG_BLOCK, &newmask, &oldmask);時屏蔽SIGALRM

  2. 調用sigsuspend(&suspmask);時解除對SIGALRM的屏蔽,然後掛起等待待。

  3. SIGALRM遞達後suspend返回,自動恢復原來的屏蔽字,也就是再次屏蔽SIGALRM

  4. 調用sigprocmask(SIG_SETMASK, &oldmask, NULL);時再次解除對SIGALRM的屏蔽。

4.7. 關於SIGCHLD信號

進程一章講過用waitwaitpid函數清理殭屍進程,父進程可以阻塞等待子進程結束,也可以非阻塞地查詢是否有子進程結束等待清理(也就是輪詢的方式)。採用第一種方式,父進程阻塞了就不能處理自己的工作了;採用第二種方式,父進程在處理自己的工作的同時還要記得時不時地輪詢一下,程序實現複雜。

其實,子進程在終止時會給父進程發SIGCHLD信號,該信號的預設處理動作是忽略,父進程可以自定義SIGCHLD信號的處理函數,這樣父進程只需專心處理自己的工作,不必關心子進程了,子進程終止時會通知父進程,父進程在信號處理函數中調用wait清理子進程即可。

請編寫一個程序完成以下功能:父進程fork出子進程,子進程調用exit(2)終止,父進程自定義SIGCHLD信號的處理函數,在其中調用wait獲得子進程的退出狀態並打印。

事實上,由於UNIX的歷史原因,要想不產生殭屍進程還有另外一種辦法:父進程調用sigactionSIGCHLD的處理動作置為SIG_IGN,這樣fork出來的子進程在終止時會自動清理掉,不會產生殭屍進程,也不會通知父進程。系統預設的忽略動作和用戶用sigaction函數自定義的忽略通常是沒有區別的,但這是一個特例。此方法對於Linux可用,但不保證在其它UNIX系統上都可用。請編寫程序驗證這樣做不會產生殭屍進程。