本文主要介绍Linux信号系统和如何使用POSIX API来响应信号。本文中的示例适用于Linux系统和大部分POSIX兼容系统。
在下列情况下,我们的应用进程可能会收到系统信号:
如需了解所有系统信号,参见 signal(7) 手册。
每个信号都关联一个默认的行为,当进程没有捕获并处理信号时,进程会按照默认的行为处理信号。
这些默认行为包括:
最传统的信号处理方式是使用 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); };
其中一些关键字段:
sigaction使用示例:
#include <stdio.h> #include <unistd.h> #include <signal.h> #include <string.h> 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 $!
注意,使用三参数版本信号处理函数时, 必须 将sa_flags字段设置为SA_SIGINFO,否则信号处理函数将无法获取到正确的 siginfo_t
对象。
对于 siginfo_t
结构体, sigaction(2) 的手册中有详细介绍,其中的几个字段非常有用:
由于信号处理函数是异步执行且无法预知执行时间,因此编码时需要特别注意异步执行产生的问题,尤其是主函数和信号处理函数之间共享的数据。
首先是编译器优化。如果一个变量在主函数中循环读取,信号处理函数中修改(例如一个退出标识),这时编译器优化可能导致信号处理函数中的修改无法让主函数感知到。例如如下代码:
#include <stdio.h> #include <unistd.h> #include <signal.h> #include <string.h> 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] 2532 jinlingjie@localhost ~/data/Downloads $ killall test jinlingjie@localhost ~/data/Downloads $ jobs [1]+ 运行中 ./test &
这是因为在O3级别的优化中,编译器发现 while
循环会不停读取 exit_flag
变量,为了加快读取速度,编译器会把该变量值直接加载到寄存器中,而不再每次从内存读取。此时信号处理函数再修改 exit_flag
变量,不会被更新到寄存器中,因此进程无法退出。对于这种场景,需要给共享变量增加 volatile
关键字,以确保进程每次读取变量时,都去内存重新获取最新的值。
上面的示例中的场景,还需要考虑对共享变量修改的原子性。在一些平台上 int
类型的读取或者写入可能不是原子的。信号系统提供sig_atomic_t对象,以确保原子的读写。
除此以外,编写信号处理函数还需要注意 信号安全 。因为信号处理函数调用的其他函数也有可能被信号中断, signal(7) 手册的Async-signal-safe functions(异步信号安全函数)章节详细列举了所有在信号处理函数中可以安全调用的函数。
如果父进程不需要获取子进程的退出状态码,也不需要等待子进程的退出,唯一的目的是清理僵尸进程。那么,父进程只需要处理SIGCHLD信号,并进行清理即可:
static void sigchld_hdl (int sig) { /* 等待所有已经退出的子进程。 * 这里使用非阻塞的调用以防止子进程在代码其他地方被清理。 */ while (waitpid(-1, NULL, WNOHANG) > 0) { } }
这是一个简单的信号处理函数,如果需要做更多的工作,请特别注意不要使用非异步信号安全的函数。
前面提到过SIGBUS信号通常是访问被映射( mmap(2) )的内存时,无法映射到对应文件(通常是文件被截断了)。这种非正常情况下,进程的一般行为是直接退出,但是如果一定要处理SIGBUS信号还是可行的。这时可以通过 sigsetjmp(3) 和 siglongjmp(3) 来跳过发生错误的地方,从而让程序继续运行。
需要特别注意的是,信号处理函数执行了 siglongjmp(3) 调用之后,代码没有继续运行下去,而是直接跳转到 sigsetjmp(3) 位置重新开始执行。如果此时代码仍然持有锁等资源,将不会释放,如果后续代码继续去竞争锁,可能会导致死锁的发生。
处理SIGSEGV(段错误)信号是可能的,但这一般是没有意义的,因为即使代码重新运行了,运行到同样的地方仍然可能发生段错误。其中一种重启程序有效的情况是通过 mmap(2) 获取到的内存有写保护,由此产生的SIGSEGV信号(可以通过信号处理函数中的siginfo_t参数获取发生原因),可能可以通过 mprotect(2) 函数来去除写保护。
如果段错误是因为栈空间不足导致的,那么这时将无法通过信号处理函数来处理SIGSEGV信号。因为信号处理函数同样需要分配栈空间来执行。这种情况下,可以通过 sigaltstack(2) 函数为信号处理函数定义独立的栈空间。
试图处理SIGABRT信号时,需要了解 abort(3) 函数的运行原理:该函数会先发送SIGABRT信号,如果该信号被忽略,或者对应的信号处理函数正常返回(没有通过 longjmp(3) 跳转),它会将信号处理函数重置为默认方式,并且重新发送SIGABRT信号信号,这将导致进程退出。因此,处理SIGABRT信号的作用可能是在进程结束前做一些最后的操作,或者使用 longjmp(3) 从新的地方开始执行。
当父进程调用 fork(2) 函数创建子进程时,子进程不会复制父进程的信号队列,即使此时父进程的信号队列非空,也会单独创建一个空的信号队列。但是,子进程会继承父进程的所有信号处理函数和信号阻塞状态。因此如果父进程已经完成对信号的设置,没有特殊情况子进程无须重新设置。
由于POSIX规范中,所有的一个进程的所有线程都有相同的进程ID(PID),向多线程进程发送信号有两种情况:
向进程发送信号的方式可以有:
sigval
参数。因此调用者可以向信号处理函数传递一个整数或者一个指针。信号处理函数可以通过 siginfo_t
参数获取该参数。 有些时候,我们需要阻塞信号,防止信号打断当前程序的执行,而不是捕获和处理信号。传统的 signal(2) 函数可以通过将信号处理函数设置为 SIG_IGN
来实现阻塞的功能。但是该方式已经废弃,建议使用 sigprocmask(2) 函数来实现信号阻塞功能,因为它提供了更多的参数,可以适用于复杂场景。
一个简单的示例:
#include <signal.h> #include <stdio.h> #include <string.h> #include <unistd.h> 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信号系统、信号的处理、发送、阻塞等做了简单的介绍。但是整个信号系统非常复杂,还有很多没有提到的内容,期待和大家继续交流。
感谢魏星对本文的审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ,@丁晓昀),微信(微信号: InfoQChina )关注我们。