如果信號的處理動作是用戶自定義函數,在信號遞達時就調用這個函數,這稱為捕捉信號。由於信號處理函數的代碼是在用戶空間的,處理過程比較複雜,舉例如下:
用戶程序註冊了SIGQUIT信號的處理函數sighandler。
當前正在執行main函數,這時發生中斷或異常切換到內核態。
在中斷處理完畢後要返回用戶態的main函數之前檢查到有信號SIGQUIT遞達。
內核決定返回用戶態後不是恢復main函數的上下文繼續執行,而是執行sighandler函數,sighandler和main函數使用不同的堆棧空間,它們之間不存在調用和被調用的關係,是兩個獨立的控制流程。
sighandler函數返回後自動執行特殊的系統調用sigreturn再次進入內核態。
如果沒有新的信號要遞達,這次再返回用戶態就是恢復main函數的上下文繼續執行了。
上圖出自[ULK]。
#include <signal.h> int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction函數可以讀取和修改與指定信號相關聯的處理動作。調用成功則返回0,出錯則返回-1。signo是指定信號的編號。若act指針非空,則根據act修改該信號的處理動作。若oact指針非空,則通過oact傳出該信號原來的處理動作。act和oact指向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]。
#include <unistd.h> int pause(void);
pause函數使調用進程掛起直到有信號遞達。如果信號的處理動作是終止進程,則進程終止,pause函數沒有機會返回;如果信號的處理動作是忽略,則進程繼續處于掛起狀態,pause不返回;如果信號的處理動作是捕捉,則調用了信號處理函數之後pause返回-1,errno設置為EINTR,所以pause只有出錯的返回值(想想以前還學過什麼函數只有出錯返回值?)。錯誤碼EINTR表示“被信號中斷”。
下面我們用alarm和pause實現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;
}main函數調用mysleep函數,後者調用sigaction註冊了SIGALRM信號的處理函數sig_alrm。
調用alarm(nsecs)設定閙鐘。
調用pause等待,內核切換到別的進程運行。
nsecs秒之後,閙鐘超時,內核發SIGALRM給這個進程。
從內核態返回這個進程的用戶態之前處理未決信號,發現有SIGALRM信號,其處理函數是sig_alrm。
切換到用戶態執行sig_alrm函數,進入sig_alrm函數時SIGALRM信號被自動屏蔽,從sig_alrm函數返回時SIGALRM信號自動解除屏蔽。然後自動執行系統調用sigreturn再次進入內核,再返回用戶態繼續執行進程的主控制流程(main函數調用的mysleep函數)。
pause函數返回-1,然後調用alarm(0)取消閙鐘,調用sigaction恢復SIGALRM信號以前的處理動作。
以下問題留給讀者思考:
1、信號處理函數sig_alrm什麼都沒幹,為什麼還要註冊它作為SIGALRM的處理函數?不註冊信號處理函數可以嗎?
2、為什麼在mysleep函數返回前要恢復SIGALRM信號原來的sigaction?
3、mysleep函數的返回值表示什麼含義?什麼情況下返回非0值?。
當捕捉到信號時,不論進程的主控制流程當前執行到哪兒,都會先跳到信號處理函數中執行,從信號處理函數返回後再繼續執行主控制流程。信號處理函數是一個單獨的控制流程,因為它和主控制流程是非同步的,二者不存在調用和被調用的關係,並且使用不同的堆棧空間。引入了信號處理函數使得一個進程具有多個控制流程,如果這些控制流程訪問相同的全局資源(全局變數、硬件資源等),就有可能出現衝突,如下面的例子所示。
main函數調用insert函數向一個鏈表head中插入節點node1,插入操作分為兩步,剛做完第一步的時候,因為硬件中斷使進程切換到內核,再次回用戶態之前檢查到有信號待處理,於是切換到sighandler函數,sighandler也調用insert函數向同一個鏈表head中插入節點node2,插入操作的兩步都做完之後從sighandler返回內核態,再次回到用戶態就從main函數調用的insert函數中繼續往下執行,先前做第一步之後被打斷,現在繼續做完第二步。結果是,main函數和sighandler先後向鏈表中插入兩個節點,而最後只有一個節點真正插入鏈表中了。
像上例這樣,insert函數被不同的控制流程調用,有可能在第一次調用還沒返回時就再次進入該函數,這稱為重入,insert函數訪問一個全局鏈表,有可能因為重入而造成錯亂,像這樣的函數稱為不可重入函數,反之,如果一個函數隻訪問自己的局部變數或參數,則稱為可重入(Reentrant)函數。想一下,為什麼兩個不同的控制流程調用同一個函數,訪問它的同一個局部變數或參數就不會造成錯亂?
如果一個函數符合以下條件之一則是不可重入的:
調用了malloc或free,因為malloc也是用全局鏈表來管理堆的。
調用了標準I/O庫函數。標準I/O庫的很多實現都以不可重入的方式使用全局資料結構。
SUS規定有些系統函數必須以綫程安全的方式實現,這裡就不列了,請參考[APUE2e]。
在上面的例子中,main和sighandler都調用insert函數則有可能出現鏈表的錯亂,其根本原因在於,對全局鏈表的插入操作要分兩步完成,不是一個原子操作,假如這兩步操作必定會一起做完,中間不可能被打斷,就不會出現錯亂了。下一節綫程會講到如何保證一個代碼段以原子操作完成。
現在想一下,如果對全局數據的訪問只有一行代碼,是不是原子操作呢?比如,main和sighandler都對一個全局變數賦值,會不會出現錯亂呢?比如下面的程序:
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位寄存器才放得下,也需要兩條指令,不是原子操作。請讀者設想一種時序,main和sighandler都對這個變數a賦值,最後變數a的值發生錯亂。
如果上述程序在64位機上編譯執行,則有可能用一條指令完成賦值,因而是原子操作。如果a是32位的int變數,在32位機上賦值是原子操作,在16位機上就不是。如果在程序中需要使用一個變數,要保證對它的讀寫都是原子操作,應該採用什麼類型呢?為瞭解決這些平台相關的問題,C標準定義了一個類型sig_atomic_t,在不同平台的C語言庫中取不同的類型,例如在32位機上定義sig_atomic_t為int類型。
在使用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寄存器,對eax和eax做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比較,如果相等,則第二條指令成了一個死循環,注意,這是一個真正的死循環:即使sighandler將a改為1,只要沒有影響Zero標誌位,回到main函數後仍然死在第二條指令上,因為不會再次從內存讀取變數a的值。
是編譯器優化得有錯誤嗎?不是的。設想一下,如果程序只有單一的執行流程,只要當前執行流程沒有改變a的值,a的值就沒有理由會變,不需要反覆從內存讀取,因此上面的兩條指令和while(!a);循環是等價的,並且優化之後省去了每次循環讀內存的操作,效率非常高。所以不能說編譯器做錯了,只能說編譯器無法識別程序中存在多個執行流程。之所以程序中存在多個執行流程,是因為調用了特定平台上的特定庫函數,比如sigaction、pthread_create,這些不是C語言本身的規範,不歸編譯器管,程序員應該自己處理這些問題。C語言提供了volatile限定符,如果將上述變數定義為volatile sig_atomic_t a=0;那麼即使指定了優化選項,編譯器也不會優化掉對變數a內存單元的讀寫。
對於程序中存在多個執行流程訪問同一全局變數的情況,volatile限定符是必要的,此外,雖然程序只有單一的執行流程,但是變數屬於以下情況之一的,也需要volatile限定:
變數的內存單元中的數據不需要寫操作就可以自己發生變化,每次讀上來的值都可能不一樣
即使多次向變數的內存單元中寫數據,只寫不讀,也並不是在做無用功,而是有特殊意義的
什麼樣的內存單元會具有這樣的特性呢?肯定不是普通的內存,而是映射到內存地址空間的硬件寄存器,例如串口的接收寄存器屬於上述第一種情況,而發送寄存器屬於上述第二種情況。
sig_atomic_t類型的變數應該總是加上volatile限定符,因為要使用sig_atomic_t類型的理由也正是要加volatile限定符的理由。
現在重新審視例 33.2 “mysleep”,設想這樣的時序:
註冊SIGALRM信號的處理函數。
調用alarm(nsecs)設定閙鐘。
內核調度優先順序更高的進程取代當前進程執行,並且優先順序更高的進程有很多個,每個都要執行很長時間
nsecs秒鐘之後閙鐘超時了,內核發送SIGALRM信號給這個進程,處于未決狀態。
優先順序更高的進程執行完了,內核要調度回這個進程執行。SIGALRM信號遞達,執行處理函數sig_alrm之後再次進入內核。
返回這個進程的主控制流程,alarm(nsecs)返回,調用pause()掛起等待。
可是SIGALRM信號已經處理完了,還等待什麼呢?
出現這個問題的根本原因是系統運行的時序(Timing)並不像我們寫程序時所設想的那樣。雖然alarm(nsecs)緊接着的下一行就是pause(),但是無法保證pause()一定會在調用alarm(nsecs)之後的nsecs秒之內被調用。由於非同步事件在任何時候都有可能發生(這裡的非同步事件指出現更高優先順序的進程),如果我們寫程序時考慮不周密,就可能由於時序問題而導致錯誤,這叫做競態條件(Race Condition)。
如何解決上述問題呢?讀者可能會想到,在調用pause之前屏蔽SIGALRM信號使它不能提前遞達就可以了。看看以下方法可行嗎?
屏蔽SIGALRM信號;
alarm(nsecs);
解除對SIGALRM信號的屏蔽;
pause();
從解除信號屏蔽到調用pause之間存在間隙,SIGALRM仍有可能在這個間隙遞達。要消除這個間隙,我們把解除屏蔽移到pause後面可以嗎?
屏蔽SIGALRM信號;
alarm(nsecs);
pause();
解除對SIGALRM信號的屏蔽;
這樣更不行了,還沒有解除屏蔽就調用pause,pause根本不可能等到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信號沒有屏蔽:
調用sigprocmask(SIG_BLOCK, &newmask, &oldmask);時屏蔽SIGALRM。
調用sigsuspend(&suspmask);時解除對SIGALRM的屏蔽,然後掛起等待待。
SIGALRM遞達後suspend返回,自動恢復原來的屏蔽字,也就是再次屏蔽SIGALRM。
調用sigprocmask(SIG_SETMASK, &oldmask, NULL);時再次解除對SIGALRM的屏蔽。
進程一章講過用wait和waitpid函數清理殭屍進程,父進程可以阻塞等待子進程結束,也可以非阻塞地查詢是否有子進程結束等待清理(也就是輪詢的方式)。採用第一種方式,父進程阻塞了就不能處理自己的工作了;採用第二種方式,父進程在處理自己的工作的同時還要記得時不時地輪詢一下,程序實現複雜。
其實,子進程在終止時會給父進程發SIGCHLD信號,該信號的預設處理動作是忽略,父進程可以自定義SIGCHLD信號的處理函數,這樣父進程只需專心處理自己的工作,不必關心子進程了,子進程終止時會通知父進程,父進程在信號處理函數中調用wait清理子進程即可。
請編寫一個程序完成以下功能:父進程fork出子進程,子進程調用exit(2)終止,父進程自定義SIGCHLD信號的處理函數,在其中調用wait獲得子進程的退出狀態並打印。
事實上,由於UNIX的歷史原因,要想不產生殭屍進程還有另外一種辦法:父進程調用sigaction將SIGCHLD的處理動作置為SIG_IGN,這樣fork出來的子進程在終止時會自動清理掉,不會產生殭屍進程,也不會通知父進程。系統預設的忽略動作和用戶用sigaction函數自定義的忽略通常是沒有區別的,但這是一個特例。此方法對於Linux可用,但不保證在其它UNIX系統上都可用。請編寫程序驗證這樣做不會產生殭屍進程。