本文主要介绍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信号系统、信号的处理、发送、阻塞等做了简单的介绍。但是整个信号系统非常复杂,还有很多没有提到的内容,期待和大家继续交流。
评论
查看更多