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