如果信號的處理動作是用戶自定義函數,在信號遞達時就調用這個函數,這稱為捕捉信號。由於信號處理函數的代碼是在用戶空間的,處理過程比較複雜,舉例如下:
用戶程序註冊了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系統上都可用。請編寫程序驗證這樣做不會產生殭屍進程。