本文主要介紹Linux信號系統(tǒng)和如何使用POSIX API來響應(yīng)信號。本文中的示例適用于Linux系統(tǒng)和大部分POSIX兼容系統(tǒng)。
Linux系統(tǒng)中的信號
在下列情況下,我們的應(yīng)用進(jìn)程可能會收到系統(tǒng)信號:
用戶空間的其他進(jìn)程調(diào)用了類似kill(2)函數(shù)
進(jìn)程自身調(diào)用了類似about(3)函數(shù)
當(dāng)子進(jìn)程退出時,內(nèi)核會向父進(jìn)程發(fā)送SIGCHLD信號
當(dāng)父進(jìn)程退出時,所有子進(jìn)程會收到SIGHUP信號
當(dāng)用戶通過鍵盤終端進(jìn)程(ctrl+c)時,進(jìn)程會收到SIGINT信號
當(dāng)進(jìn)程運(yùn)行出現(xiàn)問題時,可能會收到SIGILL、SIGFPE、SIGSEGV等信號
當(dāng)進(jìn)程在調(diào)用mmap(2)的時候失?。赡苁且驗橛成涞奈募黄渌M(jìn)程截短),會收到SIGBUS信號
當(dāng)使用性能調(diào)優(yōu)工具時,進(jìn)程可能會收到SIGPROF。這一般是程序未能正確處理中斷系統(tǒng)函數(shù)(如read(2))。
當(dāng)使用write(2)或類似數(shù)據(jù)發(fā)送函數(shù)時,如果對方已經(jīng)斷開連接,進(jìn)程會收到SIGPIPE信號。
如需了解所有系統(tǒng)信號,參見signal(7)手冊。
信號的默認(rèn)行為
每個信號都關(guān)聯(lián)一個默認(rèn)的行為,當(dāng)進(jìn)程沒有捕獲并處理信號時,進(jìn)程會按照默認(rèn)的行為處理信號。
這些默認(rèn)行為包括:
結(jié)束進(jìn)程。這是最通用默認(rèn)行為,包括SIGTERM、SIGQUIT、SIGPIPE、SIGUSR1、SIGUSR2等信號。
結(jié)束并執(zhí)行核心轉(zhuǎn)儲。包括SIGSEGV、SIGILL、SIGABRT等信號,這一般都是因為代碼中存在錯誤。
一些信號默認(rèn)會被忽略,例如SIGCHLD。
掛起進(jìn)程。SIGSTOP信號會引起進(jìn)程掛起,而SIGCOND能夠?qū)炱鸬倪M(jìn)程繼續(xù)運(yùn)行。該過程常見于在控制臺使用ctrl+z組合鍵。
信號處理
最傳統(tǒng)的信號處理方式是使用signal(2)函數(shù)裝載一個信號處理函數(shù)。但是這種方式已經(jīng)被廢棄,主要原因是在UNIX實(shí)現(xiàn)中,收到信號之后,會重置回默認(rèn)的信號處理行為。同時,該行為是不跨平臺的。因此,建議的信號處理方式是使用sigaction(2)函數(shù)。
sigaction(2)函數(shù)的原型為:
int sigaction (int signum, const struct sigaction *act, struct sigaction *oldact);
值得注意的是,sigaction(2)函數(shù)不直接接受信號處理函數(shù),而需要使用struct sigaction結(jié)構(gòu)體,其定義為:
struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void);};
其中一些關(guān)鍵字段:
sa_handler:信號處理函數(shù)的函數(shù)指針,其函數(shù)原型和signal(2)接受的信號處理函數(shù)相同。
sa_sigaction:另一種信號處理函數(shù)指針,它能在處理信號時獲取更多信號相關(guān)的信息。
sa_mask:允許設(shè)置信號處理函數(shù)執(zhí)行時需要阻塞的信號。
sa_flags:修改信號處理函數(shù)執(zhí)行時的默認(rèn)行為,具體可選值請參照手冊。
sigaction使用示例:
#include #include #include #include static void hdl (int sig, siginfo_t *siginfo, void *context){ printf (“Sending PID: %ld, UID: %ld\n”, (long)siginfo-》si_pid, (long)siginfo-》si_uid);}int main (int argc, char *argv[]){ struct sigaction act; memset (&act, ‘\0’, sizeof(act)); /* 這里使用sa_sigaction字段,因為該字段提供了兩個額外的參數(shù), 可以獲取關(guān)于接收信號的更多信息。 */ act.sa_sigaction = &hdl; /* SA_SIGINFO標(biāo)識告訴sigaction函數(shù)使用sa_sigaction字段,而非sa_handler字段*/ act.sa_flags = SA_SIGINFO; if (sigaction(SIGTERM, &act, NULL) 《 0) { perror (“sigaction”); return 1; } while (1) sleep (10); return 0;}
該示例中使用了三個參數(shù)版本的信號處理函數(shù)來響應(yīng)SIGTERM信號,編譯(假設(shè)源文件名為sig.c)并執(zhí)行程序,可以有以下輸出:
gcc -o sig sig.c./sig &kill $!
Sending PID: 16200, UID: 1000
注意,使用三參數(shù)版本信號處理函數(shù)時,必須將sa_flags字段設(shè)置為SA_SIGINFO,否則信號處理函數(shù)將無法獲取到正確的siginfo_t對象。
對于siginfo_t結(jié)構(gòu)體,sigaction(2)的手冊中有詳細(xì)介紹,其中的幾個字段非常有用:
si_code:用于標(biāo)識信號的來源,例如kill(2)、raise(3)等通過程序調(diào)用產(chǎn)生的信號,該值為SI_USER;而由內(nèi)核發(fā)送的信號,該值為SI_KERNEL。
對于SIGCHLD信號,可以從si_status字段(進(jìn)程退出碼)、si_utime字段(進(jìn)程消耗的用戶態(tài)時間)和si_stime字段(進(jìn)程消耗的內(nèi)核態(tài)時間)獲取更多信息。
對于SIGILL、SIGFPE、SIGSEGV、SIGBUS等信號,可以從si_addr字段獲取發(fā)生錯誤的內(nèi)存地址。
常見問題
由于信號處理函數(shù)是異步執(zhí)行且無法預(yù)知執(zhí)行時間,因此編碼時需要特別注意異步執(zhí)行產(chǎn)生的問題,尤其是主函數(shù)和信號處理函數(shù)之間共享的數(shù)據(jù)。
首先是編譯器優(yōu)化。如果一個變量在主函數(shù)中循環(huán)讀取,信號處理函數(shù)中修改(例如一個退出標(biāo)識),這時編譯器優(yōu)化可能導(dǎo)致信號處理函數(shù)中的修改無法讓主函數(shù)感知到。例如如下代碼:
#include #include #include #include static int exit_flag = 0;static void hdl (int sig){ exit_flag = 1;}int main (int argc, char *argv[]){ struct sigaction act; memset (&act, ‘\0’, sizeof(act)); act.sa_handler = &hdl; if (sigaction(SIGTERM, &act, NULL) 《 0) { perror (“sigaction”); return 1; } while (!exit_flag) ; return 0;}
如果使用gcc O2級別的優(yōu)化,該程序會按照預(yù)期,在接收到SIGTERM信號時退出。但是,如果優(yōu)化級別調(diào)整到O3,向進(jìn)程發(fā)送SIGTERM信號之后,進(jìn)程還會繼續(xù)運(yùn)行(假設(shè)文件名為test_sig.c):
gcc -o test -O3 test_sig.c./test &killall test
這時控制臺不會提示后臺進(jìn)程退出,使用jobs命令查看后,test進(jìn)程仍然存在:
jinlingjie@localhost ~/data/Downloads $ 。/test &[1] 2532jinlingjie@localhost ~/data/Downloads $ killall testjinlingjie@localhost ~/data/Downloads $ jobs[1]+ 運(yùn)行中 。/test &
這是因為在O3級別的優(yōu)化中,編譯器發(fā)現(xiàn)while循環(huán)會不停讀取exit_flag變量,為了加快讀取速度,編譯器會把該變量值直接加載到寄存器中,而不再每次從內(nèi)存讀取。此時信號處理函數(shù)再修改exit_flag變量,不會被更新到寄存器中,因此進(jìn)程無法退出。對于這種場景,需要給共享變量增加volatile關(guān)鍵字,以確保進(jìn)程每次讀取變量時,都去內(nèi)存重新獲取最新的值。
上面的示例中的場景,還需要考慮對共享變量修改的原子性。在一些平臺上int類型的讀取或者寫入可能不是原子的。信號系統(tǒng)提供sig_atomic_t對象,以確保原子的讀寫。
除此以外,編寫信號處理函數(shù)還需要注意信號安全。因為信號處理函數(shù)調(diào)用的其他函數(shù)也有可能被信號中斷,signal(7)手冊的Async-signal-safe functions(異步信號安全函數(shù))章節(jié)詳細(xì)列舉了所有在信號處理函數(shù)中可以安全調(diào)用的函數(shù)。
特殊信號處理
SIGCHLD信號
如果父進(jìn)程不需要獲取子進(jìn)程的退出狀態(tài)碼,也不需要等待子進(jìn)程的退出,唯一的目的是清理僵尸進(jìn)程。那么,父進(jìn)程只需要處理SIGCHLD信號,并進(jìn)行清理即可:
static void sigchld_hdl (int sig){ /* 等待所有已經(jīng)退出的子進(jìn)程。 * 這里使用非阻塞的調(diào)用以防止子進(jìn)程在代碼其他地方被清理。 */ while (waitpid(-1, NULL, WNOHANG) 》 0) { }}
這是一個簡單的信號處理函數(shù),如果需要做更多的工作,請?zhí)貏e注意不要使用非異步信號安全的函數(shù)。
SIGBUS信號
前面提到過SIGBUS信號通常是訪問被映射(mmap(2))的內(nèi)存時,無法映射到對應(yīng)文件(通常是文件被截斷了)。這種非正常情況下,進(jìn)程的一般行為是直接退出,但是如果一定要處理SIGBUS信號還是可行的。這時可以通過sigsetjmp(3)和siglongjmp(3)來跳過發(fā)生錯誤的地方,從而讓程序繼續(xù)運(yùn)行。
需要特別注意的是,信號處理函數(shù)執(zhí)行了siglongjmp(3)調(diào)用之后,代碼沒有繼續(xù)運(yùn)行下去,而是直接跳轉(zhuǎn)到sigsetjmp(3)位置重新開始執(zhí)行。如果此時代碼仍然持有鎖等資源,將不會釋放,如果后續(xù)代碼繼續(xù)去競爭鎖,可能會導(dǎo)致死鎖的發(fā)生。
SIGSEGV信號
處理SIGSEGV(段錯誤)信號是可能的,但這一般是沒有意義的,因為即使代碼重新運(yùn)行了,運(yùn)行到同樣的地方仍然可能發(fā)生段錯誤。其中一種重啟程序有效的情況是通過mmap(2)獲取到的內(nèi)存有寫保護(hù),由此產(chǎn)生的SIGSEGV信號(可以通過信號處理函數(shù)中的siginfo_t參數(shù)獲取發(fā)生原因),可能可以通過mprotect(2)函數(shù)來去除寫保護(hù)。
如果段錯誤是因為??臻g不足導(dǎo)致的,那么這時將無法通過信號處理函數(shù)來處理SIGSEGV信號。因為信號處理函數(shù)同樣需要分配??臻g來執(zhí)行。這種情況下,可以通過sigaltstack(2)函數(shù)為信號處理函數(shù)定義獨(dú)立的棧空間。
SIGABRT信號
試圖處理SIGABRT信號時,需要了解abort(3)函數(shù)的運(yùn)行原理:該函數(shù)會先發(fā)送SIGABRT信號,如果該信號被忽略,或者對應(yīng)的信號處理函數(shù)正常返回(沒有通過longjmp(3)跳轉(zhuǎn)),它會將信號處理函數(shù)重置為默認(rèn)方式,并且重新發(fā)送SIGABRT信號信號,這將導(dǎo)致進(jìn)程退出。因此,處理SIGABRT信號的作用可能是在進(jìn)程結(jié)束前做一些最后的操作,或者使用longjmp(3)從新的地方開始執(zhí)行。
信號和fork()
當(dāng)父進(jìn)程調(diào)用fork(2)函數(shù)創(chuàng)建子進(jìn)程時,子進(jìn)程不會復(fù)制父進(jìn)程的信號隊列,即使此時父進(jìn)程的信號隊列非空,也會單獨(dú)創(chuàng)建一個空的信號隊列。但是,子進(jìn)程會繼承父進(jìn)程的所有信號處理函數(shù)和信號阻塞狀態(tài)。因此如果父進(jìn)程已經(jīng)完成對信號的設(shè)置,沒有特殊情況子進(jìn)程無須重新設(shè)置。
信號和線程
由于POSIX規(guī)范中,所有的一個進(jìn)程的所有線程都有相同的進(jìn)程ID(PID),向多線程進(jìn)程發(fā)送信號有兩種情況:
向進(jìn)程發(fā)送信號(使用類似kill(2)這樣的函數(shù)直接向進(jìn)程發(fā)送信號):線程可以通過pthread_sigmask(2)單獨(dú)設(shè)置需要阻塞的信號。因此如果有線程沒有阻塞當(dāng)前發(fā)送的信號,進(jìn)程中的一個線程會收到該信號(但是沒有特殊說明具體哪個線程會收到);如果所有的線程都阻塞了當(dāng)前發(fā)送的信號,該信號會被加入進(jìn)程的信號隊列;如果進(jìn)程沒有設(shè)置當(dāng)前信號的信號處理函數(shù),并且該信號的默認(rèn)行為是終止進(jìn)程,那么整個進(jìn)程都將被終止。
向特性線程發(fā)送信號(使用pthread_kill(2)):線程可以通過pthread_kill(2)向進(jìn)程中的其他線程(或者自身)發(fā)送信號,此時信號會發(fā)送到對應(yīng)線程的信號隊列中。同時操作系統(tǒng)也可能會向特性線程發(fā)送諸如SIGSEGV信號。如果接收信號的線程沒有處理對應(yīng)的信號,且該信號的默認(rèn)行為是終止進(jìn)程,那么該線程所在的進(jìn)程都將被終止。
信號發(fā)送
向進(jìn)程發(fā)送信號的方式可以有:
通過鍵盤交互:一些鍵盤的組合鍵,可以向控制臺正在執(zhí)行的進(jìn)程發(fā)送信號。
CTRL+C:發(fā)送SIGINT信號,該信號默認(rèn)行為是終止進(jìn)程。
CTRL+\:發(fā)送SIGQUIT信號,該信好默認(rèn)行為是終止進(jìn)程并核心轉(zhuǎn)儲。
CTRL+Z:發(fā)送SIGSTOP信號,該信號默認(rèn)行為是掛起進(jìn)程。
kill(2):kill(2)函數(shù)接受兩個參數(shù),一個是信號發(fā)送的進(jìn)程ID,一個是需要發(fā)送的信號。其中的進(jìn)程ID有一些特殊的約定。
0:如果PID為0,信號發(fā)送的目標(biāo)是當(dāng)前進(jìn)程組的所有進(jìn)程。
-1:如果PID為-1,信號發(fā)送的目標(biāo)是所有(有權(quán)限發(fā)送信號)的進(jìn)程。
《 -1:如果PID小于-1,信號發(fā)送的目標(biāo)是進(jìn)程ID為-PID的進(jìn)程組。
向進(jìn)程自身發(fā)送信號:進(jìn)程可以通過調(diào)用raise(3)、abort(3)等函數(shù)向自身發(fā)送信號。
raise(3):可以向進(jìn)程發(fā)送指定信號,需要注意的是,在多線程環(huán)境中,只會向當(dāng)前線程發(fā)送信號。
abort(3):向當(dāng)前進(jìn)程發(fā)送SIGABRT信號,前文已經(jīng)提到過,該函數(shù)會重置信號處理函數(shù),因此無需關(guān)心進(jìn)程是否已經(jīng)處理了SIGABRT信號。
sigqueue(2):該函數(shù)和kill(2)函數(shù)類似,但是多了一個sigval參數(shù)。因此調(diào)用者可以向信號處理函數(shù)傳遞一個整數(shù)或者一個指針。信號處理函數(shù)可以通過siginfo_t參數(shù)獲取該參數(shù)。
信號阻塞
有些時候,我們需要阻塞信號,防止信號打斷當(dāng)前程序的執(zhí)行,而不是捕獲和處理信號。傳統(tǒng)的 signal(2)函數(shù)可以通過將信號處理函數(shù)設(shè)置為SIG_IGN來實(shí)現(xiàn)阻塞的功能。但是該方式已經(jīng)廢棄,建議使用sigprocmask(2)函數(shù)來實(shí)現(xiàn)信號阻塞功能,因為它提供了更多的參數(shù),可以適用于復(fù)雜場景。
一個簡單的示例:
#include #include #include #include static int got_signal = 0;static void hdl (int sig){ got_signal = 1;}int main (int argc, char *argv[]){ sigset_t mask; sigset_t orig_mask; struct sigaction act; memset (&act, 0, sizeof(act)); act.sa_handler = hdl; if (sigaction(SIGTERM, &act, 0)) { perror (“sigaction”); return 1; } sigemptyset (&mask); sigaddset (&mask, SIGTERM); if (sigprocmask(SIG_BLOCK, &mask, &orig_mask) 《 0) { perror (“sigprocmask”); return 1; } sleep (10); if (sigprocmask(SIG_SETMASK, &orig_mask, NULL) 《 0) { perror (“sigprocmask”); return 1; } sleep (1); if (got_signal) puts (“Got signal”); return 0;}
上述示例展示了通過sigprocmask(2)函數(shù)來阻塞SIGTERM信號10秒,此時如果進(jìn)程接收到了SIGTERM信號,會被加入到進(jìn)程的信號隊列中。解除對SIGTERM信號的阻塞,此時如果之前的信號隊列中有SIGTERM信號,或者新收到了SIGTERM信號,就會執(zhí)行對應(yīng)的信號處理函數(shù)。
阻塞信號使用的一個場景就是防止信號的競爭。一些函數(shù)(如select(2)、poll(2))會阻塞當(dāng)前函數(shù)執(zhí)行,這時在異常的情況下,這些函數(shù)會期望通過信號來中斷當(dāng)前的阻塞操作。但是,如果此時程序還設(shè)置了其他信號處理函數(shù),這時信號可能會被設(shè)置的信號處理函數(shù)消費(fèi),導(dǎo)致阻塞操作的函數(shù)仍然執(zhí)行,無法中斷。
遇到這種情況,就需要使用sigprocmask(2)配合支持重置sigmask的阻塞函數(shù)(如pselect(2)poll(2)),大致的示例代碼片段如下:
sigemptyset (&mask);sigaddset (&mask, SIGTERM);if (sigprocmask(SIG_BLOCK, &mask, &orig_mask) 《 0) { perror (“sigprocmask”); return 1;}while (!exit_request) { /* 如果在這里接收到信號,信號會被阻塞, * 直到取消阻塞(下面pselect實(shí)現(xiàn)) */ FD_ZERO (&fds); FD_SET (lfd, &fds); res = pselect (lfd + 1, &fds, NULL, NULL, NULL, &orig_mask); /* 下面繼續(xù)文件描述符操作 */}
后記
本文對Linux/UNIX信號系統(tǒng)、信號的處理、發(fā)送、阻塞等做了簡單的介紹。但是整個信號系統(tǒng)非常復(fù)雜,還有很多沒有提到的內(nèi)容,期待和大家繼續(xù)交流。
評論