信号的基本概念和信号处置函数 - 《Linux/UNIX系统编程手册》读书笔记【2】

本文是在阅读《Linux/UNIX系统编程手册》1第20、21、22章做的笔记。信号在Linux中非常常用,学习信号可以对程序异常崩溃有一个更深入的理解,同时也学习到如何写信号处置函数。也能从信号的角度看到程序为何崩溃,对分析程序崩溃的问题有一定帮助。

信号是事件发生时对进程的通知机制。针对每种信号定义了唯一的整数,从1开始顺序增加,<signal.h>以SIGxxxx的形式对信号整数进行宏定义。信号分为两大类,第一组为标准信号也就是我们通常使用的信号类型,Linux中编号范围为1~31。另一类信号为实时信号,实时信号相对于标准信号有队列管理、在传递顺序有所保证并且可以伴随数据传递,主要用于进程间通信。本文主要讨论标准信号。

信号因某些事件产生到进程处理期间,处于等待(pending)状态。信号在传递给进程时会打断进程的执行,视具体的信号执行如下默认操作之一:

  • 忽略信号: 内核将信号丢弃,对进程不会产生任何影响。
  • 终止进程: 进程会异常终止。
  • 产生coredump并终止: 将进程的虚拟内存镜像存储为coredump文件,同时进程异常终止。
  • 停止进程: 暂停进程的执行。
  • 继续进程: 恢复暂停的进程执行。

除了以上特定信号的默认行为之外,程序也能改变某些信号到达时的默认行为。但并不是所有的信号都可以修改其默认行为。程序可以通过signal()sigaction()设置某个信号的处置方式为如下之一:

  • 捕获: 执行用户给定的处置函数。
  • 忽略: 忽略信号。
  • 采取默认的行为,撤销之前对该信号处置的修改。

为了确保一段代码不被某些信号所中断,程序可以添加某些信号到进程的信号掩码中,这样信号到达时会被阻塞。直到程序从信号掩码中删除,阻塞的信号才会被处理。

Linux对标准信号的编号为1~31,有些信号名称由于为了与其他UNIX实现保持源码兼容有别名。下面对一些常用的信号做介绍。括号中是信号值,不同Linux版本可能不同。

  • SIGABRT(6): 但进程主动调用abort()函数时,系统向进程发送该信号。默认情况下会终止进程并且产生coredump文件。
  • SIGALRAM(14): 调用alram()setitimer()设置的定时器到期,内核将产生该信号,默认行为是终止进程。
  • 硬件异常产生的信号不可被忽略、阻塞或从信号处置函数中正常返回,否则会产生未定义的行为。以下是硬件异常产生的信号。
    • SIGBUS( 7): 总线错误,对内存地址的引用不合法。
    • SIGSEGV(11): 对内存地址的引用不合法。
    • SIGFPE( 8): 算术错误,比如除以0。
    • SIGILL( 4): 进程试图执行非法(格式不正确)的机器语言指令。
  • SIGCHLD(17): 内核向父进程发送该信号通知某一子进程终止\停止\恢复,详情参考sigchld信号
  • 作业控制信号: 暂停和继续的信号,参考在Shell中使用作业控制
    • SIGSTP(20): 停止进程。命令行中输入ctrl+z可以向前台进程组发送该信号,并且将进程组放入后台。
    • SIGCONT(18): 继续进程,默认情况忽略该信号,进程可以捕获该信号,以便在恢复运行时执行某些操作。
  • SIGSTOP(19): 必停进程,无法阻塞、忽略或者捕获。
  • SIGKILL( 9): 必杀信号,无法阻塞、忽略或者捕获。
  • SIGTERM(15): 用来终止进程的标准信号,命令kill和killall发送的默认信号。好的程序应该考虑为SIGTERM设置处置函数以便于终止时清除临时文件和释放其它资源。故好的提前终止进程的方法是,先发送SIGTERM来终止进程,把SIGKILL作为最后的手段。在另一篇文章中从音频播放c++类的实现学习Linux系统子进程管理提前结束进程采用的SIGKILL信号并不规范。
  • SIGHUP( 1): 当终端断开时,系统将会发送该信号给Shell进程,参考关闭终端-sighup信号,默认行为是终止进程。一些守护进程如init、httpd和inetd等捕获了该信号,在收到该信号时会重新初始化并且重读配置文件。
  • SIGQUIT( 3): 默认终止进程并且产生coredump。命令行中输入ctrl+\可以向前台进程组发送该信号。
  • SIGPIPE: 当某一进程试图向管道、FIFO或socket写入信息时,这些设备无相应的读进程,通常是由于读进程close文件描述符,默认行为是终止进程。服务器程序通常忽略此信号,否则如果对方断开连接,继续write的话程序会意外终止

在多线程程序中,使用signal的第一原则就是不要使用signal。2

UNIX信号模型是基于UNIX进程模型而设计的,问世比Pthreads要早几十年,自然而然信号与线程模型二者结合使用会非常复杂。两本书12都不建议在多线程程序中使用signal。以下是多线程遇到信号的一些关键点:

  • 信号的动作属于进程层面,如果动作是停止或者终止则会终止该进程的所有线程。
  • 信号的处置函数属于进程层面,某个线程设置信号处置函数则所有线程都共享该信号处置函数,某个线程将某个信号忽略,则所有线程都会忽略该信号。
  • 信号的发送一般针对整个进程,如果该进程为此信号设置了处置函数,则内核会任选一条线程来执行信号处置函数。
  • 信号的发送也可针对特定线程,有如下三种情况信号针对线程直接由该线程执行处置函数:
    • 信号的产生源于线程的硬件错误,如SIGBUSSIGFPESIGILLSIGSEGV
    • 但线程试图向一个已经关闭的管道、FIFO或socket写入信息时产生的SIGPIPE信号。
    • 由函数pthread_kill()pthread_sigqueue()所产生的信号,这些函数允许在同一进程下向其它线程发送信号。下文的raise()函数就是调用pthread_kill()向调用线程发送信号。

在《Linux多线程服务端编程:使用muduo C++网络库》2中给出了多线程使用信号的建议,包括:

  • 不要用signal作为IPC手段,包括不要使用SIGUSR1等信号来触发服务端的行为。
  • 不要使用基于signal实现的定时函数,包括alarm/ualarm/setitimer/timer_create/sleep/usleep等等。
  • 不主动处理各种异常信号,只用默认语义: 结束进程。除了一个例外SIGPIPE,服务器程序通常忽略此信号。
  • 在没有别的替代方法的情况下,比方说需要处理SIGCHLD信号,可以把异步信号转换成同步的文件描述符事件。传统做法是在signal_handler里往特定的pipe(2)写一个字节,主进程从中读取事件,从而将其纳入到统一的IO事件处理框架之中。现代的Linux增加了signalfd(2)把信号直接转换成文件描述符事件,从根本上避免使用signal_handler

在Shell中一般使用kill命令向指定进程发送指定信号,kill()系统调用则可以在代码中向指定进程发送指定信号。函数定义如下:

c

#include <signal.h>
int kill(pid_t pid, int sig);

其pid参数表示具体发送信号的进程,意义如下:

  • pid > 0: 发送给pid指定的进程。
  • pid == 0: 发送给调用进程在同一进程组的所有子进程,包括调用进程自己。
  • pid == -1: 发送给调用进程所有有权发送信号的所有进程,除去调用进程自身和init进程(PID=1)。如果一个特权进程调用,那么会发送信号给系统中除去上述两个进程之外的所有进程。
  • pid < -1: 发送给进程组id==abs(pid)的所有进程。另一个函数killpg()实现同样的功能,kill(-pgrp, sig)等价于killpg(pgrp, sig)。在打断播放的音频进程组的应用使用killpg()杀死进程组的所有进程。

可以通过kill()检查另一个进程是否存在,将参数sig指定为0,则不会发送信号影响另一个进程。若调用失败且errno为ESRCH表明进程不存在。若调用失败且errno为EPERM,表明进存在但调用进程无权向目标进程发送信号,或者调用成功,都表示进程存在。

进程可以调用raise()函数向自身发送信号,其定义如下:

c

#include <signal.h>
int raise(int sig);

在单线程程序中,调用raise()相当于调用kill(getpid(), sig)。而对于多线程程序,则相当于pthread_kill(pthread_self(), sig)意味着将信号传递给调用raise()的线程,而不是kill(getpid(), sig)会传递给任意线程。

如果信号是同步产生的,例如进程本身执行造成的信号,如硬件异常SIGBUSSIGFPESIGILLSIGSEGV等或者进程调用上述的raise()函数,会立即传递信号。

而对于异步产生的信号(即引发信号产生的事件来源于内核或者其它进程),信号的产生和实际传递给进程之间存在延迟,此时信号处于等待(pending)状态。而传递给进程的时机是进程正在执行且发生由内核态到用户态的下一次切换时,意味着以下时刻才会传递信号:

  • 进程再度获得调度。所以可能在任意地方中断进程执行流,开始执行信号处置函数。
  • 系统调用完成时。信号的传递可能引起正在阻塞的系统调用过早完成。

如果进程某一个时刻有多个信号等待时,就目前Linux而言,会按照信号的编号升序排序,信号编号小的优先于信号编号大传递给进程。但是不能对该顺序由任何依赖,SUSv3标准规定该顺序由系统实现而定。

当多个解除了阻塞的信号在等待传递时,如果在信号处置函数执行期间发生了内核态到用户态的切换,那么将中断处置函数的执行,转而去调用第二个信号的处置函数。如下图所示。

对多个解除信号的传递
对多个解除信号的传递

UNIX系统对改变信号处置函数提供了两种方法: signal()sigaction()signal()系统调用时设置信号处置的原始API,比sigaction()简单。当然sigaction()提供了signal()所不具备的功能。signal()在不同的UNIX实现之间存在差异,所以对于可移植性有追求的程序应该使用sigaction()

signal()函数虽然记录在Linux手册页的第2部分,但实际被实现为基于sigaction()系统调用的glibc库函数。

函数原型如下:

c

#include <signal.h>
typedef void(*sighandler_t)(int);
sighandler_t signal(int sig, sighandler_t handler);

参数sig表示希望修改处置函数的信号编号;参数handler表示处置函数的地址,处置函数的声明形式为: void handdler(int sig);返回值则是之前的信号处置函数地址,或者是SIG_DEL默认处置、SIG_IGN忽略处置和SIG_ERR表示调用失败。

在为signal()指定handler参数时,可以使用如下值来代替函数地址:

  • SIG_DFL: 将信号处置函数重置为默认值。
  • SIG_IGN: 忽略该信号。

sigaction()系统调用用法比signal()复杂,当更具灵活性和移植性。其函数原型如下:

c

#include <signal.h>
int sigaction(int sig, constr struct sigaction *act, struct sigaction *oldact);

参数sig表示想要操作的信号编号,该参数是除去SIGKILLSIGSTOP之外的任何信号。参数act是一枚指针,指向描述信号新处置的数据结构,如果仅对现有的处置感兴趣可以将其设置为NULL。参数oldact是指向同一类型的指针,用来返回之前信号处置的相关信息,如果不想获取此类信息,可以将其指定为NULL。act和oldact所指向的数据结构如下:

c

struct sigaction {
  void (*sa_handler)(int);     // 信号处置函数地址
  sigset_t sa_mask;            // 处置函数阻塞的信号集合
  int sa_flags;                // 设置标志位
  void (*sa_restorer)(void);  // 不是给应用程序使用
};

数据结构sigaction中字段sa_handler字段对应signal()的handler参数,可以指定为信号处置函数的地址或常量SIG_IGNSIG_DFL之一。仅当sa_handler是信号处置函数的地址时,才会对sa_masksa_flags字段进行处理。字段sa_restorer供内核使用,应用程序不应该使用。

字段sa_mask定义了一组信号集合,表示在调用信号处置函数时阻塞该集合中的所有信号。此外,引发处置函数的信号将自动加在这个集合中,也就是如果同一个信号到达,信号处置函数不会递归的中断自己。由于不会对遭遇阻塞的信号进行排队处理,如果执行中重复收到这些信号中的任何信号,在解除限制后对信号的传递也是一次性的。

字段sa_flags定义位掩码,用于设置信号处理过程中的各种选项,可以使用或运算相加设置。(以下仅为部分重要选项)

  • SA_NOCLDSTOP: 若sig为SIGCHLD信号,则当子进程终止或恢复时不会产生该信号给调用进程。
  • SA_NOCLDWAIT: 若sig为SIGCHLD信号,则当子进程终止时不会将其转化成僵尸进程。和设置SIGCHLD的处置为SIG_IGN主要区别在于,SA_NOCLDWAIT允许系统在子进程终止时向进程发送SIGCHLD信号,虽已经无法调用wait()来获取子进程的状态。但该特性并不在标准中保证,不过在包括Linux的一些UNIX实现中,内核确实会在设置该标志的情况下为父进程产生SIGCHLD信号。
  • SA_NODEFER: 不会在执行处置函数时将触发信号放入到阻塞信号集合中。
  • SA_RESTART: 自动重启由信号处置程序中断的系统调用。后边由详细介绍。

多个信号的集合可用sigset_t数据结构来表示,Linux中是位掩码实现,但标准并为对实现有要求。所以在初始化一个sigset_t结构时,为了可移植性不可使用memset()等清零初始化,因为有可能实现采用位掩码之外的结构来表示信号集。必须使用如下函数: sigemptyset()函数初始化一个空的信号集,而sigfillset()函数初始化一个满的信号集,其包含所有信号包括实时信号。

c

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);

信号集合初始化后,可以分别使用sigaddset()sigdelset()函数向信号集合加入或删除指定信号。

c

#include <signal.h>
int sigaddset(sigset_t *set, int sig);
int sigdelset(sigset_t *set, int sig);

使用sigismember()函数可以测试指定信号是否在指定信号集中。GUN C库还实现了3个非标准函数: sigandset()求两个信号集的交集; sigorset()求两个信号集的并集; sigisemptyset()判断信号集是否为空。

内核为每个进程维护一组信号掩码集合,表示阻塞一组信号对该进程传递。直至进程的信号掩码移出该信号,从而解除阻塞为止。并且并不会对阻塞的信号做排队处理。对于多线程程序,信号掩码属于线程属性,每个线程可以使用pthread_sigmask()函数来独立检查和修改其信号掩码。

操作信号掩码有如下几种方式:

  • 使用sigprocmask()系统调用,可以显式向信号掩码中加入或删除信号。
  • 默认情况下调用信号处置函数时,会将引发调用的信号自动添加到信号掩码中。
  • 调用sigaction()时指定一组信号,当调用该处置函数时程序会将该组信号添加到信号掩码中。

可以调用sigpending()获取当前正在被阻塞的信号,该函数返回处于等待状态的信号集。

调用pause()将暂停进程的执行,直到信号来临。

通过前面的介绍,信号可能会在任意地方打断正在执行的进程开始执行信号处置函数,甚于信号处置函数本身也可能被打断。那么对于信号处置函数就要求可重入

可重入概念: 如果同一个进程多个线程可以安全的同时调用某一函数并且函数可以交叉执行。所谓交叉执行就表示不能使用一些同步锁,同步锁可以让函数线程安全但不能保证可重入。

线程安全和可重入: 可重入的函数一定是线程安全,反过来却不一定。通过同步锁来实现的线程安全函数一定不是可重入的,想象刚一上锁要修改数据时被打断,又重入该函数又会上同一个锁,假如这个锁是不可重入的则直接死锁,即使是可重入的锁对于锁所保护的临界区数据也被修改两次,因为打断函数执行修改完后又会返回之前的地方继续执行修改数据。

信号处置函数如果需要修改全局数据,那么被修改的变量必须是volatile sig_atomic_t类型,否则被打断的进程在恢复执行后无法立刻看到信号处置函数改动后的数据,因为编译器可能假定该变量不会被他处修改从而优化了内存访问。并且使用sig_atomic_t变量唯一能做的是在信号处置函数中设置,在主程序中进行检查,反之也可。

编写信号处置函数有如下两种选择:

  1. 确保信号处置函数本身是可重入的,并且只调用异步信号安全的函数
  2. 当主程序执行不安全函数或是去操作信号处置函数也可能修改的全局数据结构时,阻塞信号的传递。在复杂程序中难以保证。

如果使用同一处置函数来处理不同信号或者调用sigaction()时设置了SA_NODEFER标志,那么处置函数可能会中断自己。因此,处置函数如果更新了全局或局部静态变量,即使主程序不使用这些变量,该函数依然是不可重入的。

综上所述,可重入函数如果要修改全局或局部共享变量,则该变量必须是volatile sig_atomic_t类型,否则该函数是不可重入函数

异步信号安全函数指的是某一函数是可重入或者信号处置函数无法将其中断的函数。对于系统函数有如下:

12345
_Exit() (v3)_exit()abort() (v3)accept() (v3)access()
aio_error() (v2)aio_return() (v2)aio_suspend() (v2)alarm()bind() (v3)
cfgetispeed()getpid()getppid()getsockname() (v3)getsockopt() (v3)
getuid()kill()link()listen() (v3)lseek()
lstat() (v3)mkdir()sigdelset()sigemptyset()sigfillset()
sigismember()signal() (v2)sigpause() (v2)sigpending()sigprocmask()
sigqueue() (v2)sigset() (v2)sigsuspend()cfgetospeed()cfsetispeed()
cfsetospeed()chdir()chmod()chown()clock_gettime() (v2)
close()connect() (v3)creat()dup()dup2()
execle()execve()fchmod() (v3)fchown() (v3)fcntl()
fdatasync() (v2)fork()fpathconf() (v2)fstat()fsync() (v2)
ftruncate() (v3)getegid()geteuid()getgid()getgroups()
getpeername() (v3)getpgrp()mkfifo()open()pathconf()
pause()pipe()poll() (v3)posix_trace_event() (v3)pselect() (v3)
raise() (v2)read()readlink() (v3)recv() (v3)recvfrom() (v3)
recvmsg() (v3)rename()rmdir()select() (v3)sem_post() (v2)
send() (v3)sendmsg() (v3)sendto() (v3)setgid()setpgid()
setsid()setsockopt() (v3)setuid()shutdown() (v3)sigaction()
sigaddset()sleep()socket() (v3)sockatmark() (v3)socketpair() (v3)
stat()symlink() (v3)sysconf()tcdrain()tcflow()
tcflush()tcgetattr()tcgetpgrp()tcsendbreak()tcsetattr()
tcsetpgrp()time()timer_getoverrun() (v2)timer_gettime() (v2)timer_settime() (v2)
times()umask()uname()unlink()utime()
wait()waitpid()write()

除该表之外所有的函数对于信号来讲都是不安全的,但当且仅当信号处置函数中断了不安全函数的执行并且信号处置函数也调用了这个不安全的函数时才会不安全。

一些阻塞的系统调用(blocking system call),例如read()当系统调用正在阻塞时,信号传递来引发处置函数的调用后,默认情况下,系统调用会失败,并将errno置为EINTR。(对于非阻塞系统调用则系统会执行完成后才响应信号,内核态切换到用户态才会执行信号处置函数)

通过该特性可以使用定时器产生SIGALRM来设置read()之类的阻塞系统调用超时。不过,一般的情况是希望被信号中断的系统调用得以继续运行。为此需要手动重启系统调用。在GUN C库中提供了非标准的宏TEMP_FAILURE_RETRY()用于重启系统调用,定义类似如下:

c

#include <unistd.h>
#define TEMP_FAILURE_RETRY(stmt) while((stmt) == -1 && errno == EINTR);
// 例子
TEMP_FAILURE_RETRY(cnt = read(fd, buf, BUF_SIZZE));
if (cnt == -1)
  errExit("read");

另外的一种方式是在sigaction()创建信号处置函数时,设置SA_RESTART标志,从而针对该信号触发的信号处置函数执行完毕后会自动重启被中断的阻塞系统调用。不幸的是,并非所有的阻塞系统调用都可以通过指定该标志位进行重启。在Linux上以下阻塞系统调用以及构建于其上的库函数即使指定该标志也不会自动重启:

  • poll()ppoll()select()pselect()这些 I/O 多路复用调用。(SUSv3明文规定,无论设置SA_RESTART标志与否,都不对select()pselect()遭处理器函数中断时的行为进行定义)
  • Linux 特有的epoll_wait()epoll_pwait()系统调用。
  • Linux 特有的io_getevents()系统调用。
  • 操作System V消息队列和信号量的阻塞系统调用:semop()semtimedop()msgrcv()msgsnd()。(虽然System V原本并未提供自动重启系统调用的功能,但在某些UNIX实现上,如果设置了SA_RESTART标志,这些系统调用还是会自动重启)
  • inotify文件描述符发起的read()调用。
  • 用于将进程挂起指定时间的系统调用和库函数:sleep()nanosleep()clock_nanosleep()
  • 特意设计用来等待某一信号到达的系统调用:pause()sigsuspend()sigtimedwait()sigwaitinfo()

在Linux上进程因为信号(SIGSTOPSIGSTP等)而停止后又受到SIGCONT信号继续执行时,也会使得某些系统调用产生EINTR错误。以下系统调用和函数存在这一行为: epoll_pwait()epoll_wait()、对inotify文件描述符执行的read()调用、semop()semtimedop()sigtimedwait()igwaitinfo()。故在使用这些函数时需要添加代码来重新启动这些系统调用,即使程序未设置信号处置函数。