概述
信号是类UNIX系统中存在一种异步通信机制,用于内核通知用户进程系统里发生了某个事件,例如,编写过应用程序的朋友应该都会遇到过”段错误“,引起段错误的信号就是SIGSEGV,此外,当用户键入Ctrl+C之后,就可以停止前台的终端进程,或者在终端通过kill命令就可以杀死某个进程。
在编写较为复杂的应用程序程序时,肯定会涉及到信号的检测和处理,比如,在编写终端处理程序时,如何处理SIGINT信号(Ctrl+C)、SIGTSTP(Ctrl+Z)等信号;在编写TCP通信程序时,如何处理SIGPIPE信号;以及操作系统对于慢速系统调用被信号中断的处理方式等。可以看到信号处理进行类Unix系统应用程序编写中,占有非常重要的地位。本文会从信号的基本概念开始,初步探究信号的基本原理,然后,着重总结信号处理程序的编写规范,最后,分析一下,信号涉及到的并发问题。
好了,废话不多说,下面就开始信号的讲解。
信号基本原理
类似于硬件中断,信号本质上是系统发生一种**“软件中断”**,当操作系统内核检测到某类事件时,便会通过信号的方式通知用户进程,内核会在合适的时机调用此类信号事件对应的信号处理程序,完成信号的处理。每个信号都会绑定一段信号处理程序,这段程序可以是默认的,也可以被应用程序重新定义(注意:SIGKILL,SIGSTOP不能被用户捕捉)。
基本术语
信号从产生到被目的进程处理一般分为两个过程:
- 发送信号 当引发信号的事件发生时,内核就会检测到该事件,并向进程(组)发送对应的信号,内核通过更新进程上下文中涉及信号处理的标志位来通知进程发生了信号,这称为信号的发生(generation)。信号发生的原因可能是:1)硬件异常事件,比如除0、非法指令;2)终端通过kill命令或者应用程序通过kill,raise来显示的发送信号,kill可以将信号发送给进程或进程组,raise允许进程向自身发送信号。
- 递送信号 当内核检测到进程有未处理的信号时,就强制进程中断当前的指令处理,转而去处理信号,这称为信号的递送(delivery)。进程可以选择忽略、终止或通过**”信号处理程序“**完成信号的处理。
- 未决的信号 当信号发生之后,信号被递送之间的这段时间,称为未决的信号(peding signal),任何时刻,一种类型的信号至多只会有一个未决的信号,如果对于信号s,进程存在一个未决的s信号,那么之后对于该进程的所有的s信号,都是简单的丢失,不会排队。一个未决的信号最多只能被**”递送“**一次。
- 阻塞信号 进程可以阻塞某个信号,如果一个信号被阻塞了,那内核只会**“发送”信号,不会“递送”信号。如果内核向进程发送了一个被阻塞的信号,而且对该信号的动作是默认动作或者捕捉该信号,这该进程将此信号保持为“未决的信号”状态,直到该进程1)对此信号解除了阻塞;2)将对该信号的动作更改为忽略。这里需要的注意的是,内核在递送信号到进程时,才会决定对于信号的处理动作,于是,在信号”递送“**之前,仍可以改变对于信号的处理动作。
- 信号集合 内核为每个进程在pending位向量中,维护着”未决的信号”的集合,在信号掩码(signal mask)位向量中维护着被阻塞的信号集合。
发送信号
类Unix系统提供了很多种向进程发送信号的机制,这些机制都是基于进程组这个概念,下面讲到信号目的进程时,会涉及到进程组的概念。
进程组
每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。getpgrp返回当前进程所属的进程ID。默认的,子进程和父进程同属于一个进程组,一个进程可以通过setpgid函数来改变自己或者其他进程的进程组。
#include <unistd.h> int setpgid(pid_t pid, pid_t pgid); 返回:若成功则返回0, 若失败返回-1;
/bin/kill命令
/bin/kill命令可以向另外的**进程(组)**发送任意的信号,其格式如下:
# /bin/kill -signal pid
比如,
# /bin/kill -15 12345
向进程12345发送信号15。
# /bin/kill -15 -12345
一个负的pid表示信号被发送到进程组ID为12345的每个进程。
键盘发送信号
在键盘上输入Ctrl+C会导致内核发送一个SIGINT信号到前台进程组中的每个进程。默认情况下,结果时终止前台进程。类似的,输入Ctrl+Z会发送一个SIGTSTP信号到前台进程组中的每个进程,默认情况下,结果时停止前台进程。
kill/raise函数
#include <signal.h> int kill(pid_t pid, int signo); int raise(int signo); 两个函数返回值:若成功则返回0, 若出错则返回-1
调用raise(signo)等价于调用
kill(getpid(), signo);
对于kill函数中的pid有几种情况:
- pid > 0 :将该信号发送给进程ID为pid的进程;
- pid == 0:将该信号发送给与发送进程同属于同一进程组的所有进程(包括发送进程自己),而且发送进程具有向这些进程发送信号的权限;
- pid < 0 :将该信号发送给其进程组ID等于pid的绝对值,并且发送进程具有向其发送信号的权限;
- pid == -1:将该进程发送给进程有权限向他们发送信号的系统上的所有进程。
接收信号
内核负责检查进程是否存在未被阻塞的未决信号,并生成信号集合(pending & ~ signal_mask),如果集合为空,那么进程继续执行;如果集合非空,那内核选择集合中的某个信号(通常是最小的),强制要求进程“递送”该信号,并触发对于该信号的处理动作,信号处理完毕之后,如果进程没有退出,进程继续执行。每个信号都有一个默认的的信号处理动作,包括如下几种:
- 进程终止;
- 进程终止并转储;
- 进程挂起直到被SIGCONT信号重启;
- 进程忽略该信号。
可以通过man 7 signal命令查看Linux系统中信号的默认处理动作。
signal
除了信号的默认处理动作,应用程序还可以通过signal函数显示的设置某个信号的处理动作(除了SIGKILL和SIGSTOP信号,他们的默认行为是不能被修改的)。signal函数的原型如下:
#include <signal.h> void (*signal(int signo, void (*func)(int))) (int); 返回值:成功返回信号以前的处理配置,出错返回SIG_ERR;
可以看到这个函数原型比较的复杂,我们通过typedef来简化一下:
#include <signal.h> typedef void *Sigfunc(int); Sigfunc signal(int signo, Sigfunc *handler); 返回值:成功返回信号以前的处理配置,出错返回SIG_ERR;
其中,handler表示信号signo所绑定的信号处理函数。
handler可以为下面三种方式之一:
- SIG_IGN:忽略类型为signo的信号;
- SIG_DFL:将类型为signo的信号处理行为恢复为默认行为;
- 用户自定义的信号处理函数,函数原型为Sigfunc。
SIG_IGN、SIG_DFL、SIG_ERR宏的定义十分的有趣:
#define SIG_ERR (void (*)()) -1 #define SIG_DFL (void (*)()) 0 #define SIG_IGN (void (*)()) 1
由于历史原因,signal函数可能存在几个问题:
- 信号处理行为单次有效:在有些老的Unix系统中,进程每次接收到信号并对其处理之时,随即将该信号动作复位为默认值,所以,需要重复调用signal函数达到重复处理某个信号的目的,比如典型的使用signal场景如下(这里以SIGINT为例):
int sig_int(); ... signal(SIGINT, sig_int); ... sig_int() { signal(SIGINT, sig_int); ... }
- 导致信号丢失:由于signal的第一个原因,容易导致信号出现丢失的情况,还是上面的代码,在sig_int中,再次调用signal之前,存在一个时间窗口,可能会发生了SIGINT信号,这时即便再次调用signal函数也不会捕捉到该信号,这就出现了信号的丢失。
阻塞信号
Linux系统提供了两种阻塞信号的方式:隐式方式和显示方式;
- 隐式阻塞方式:内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号,即对于同一个信号类型,一个进程只能串行处理该信号类型,处理过程中”发生“的该信号,内核会对其阻塞,使其处于”未决的“状态,直到上一个信号处理完毕。
- 显示阻塞方式:应用程序使用sigpromask函数和其辅助函数,明确的阻塞和解阻塞特定的信号。
类Unix系统中,关于信号集合有一个特殊的数据类型:sigset_t,可以通过该数据类型完成对于系统中信号的阻塞和解阻塞操作,围绕该数据类型,系统定义一套工具函数,比如,sigemptyset、sigfillset、sigaddset、sigdelset、sigismember,可以通过man函数查询它们的具体用法。
sigpromask函数可以检测或更改进程的信号屏蔽字,其函数原型如下:
#include <signal.h> int sigpromask(int how, const sigset_t *restrict set, sigset_t *restrict oset); 返回值:成功返回0, 失败返回-1;
- 若oset非空,其返回进程当前的信号屏蔽字;
- 若set非空,则根据参数how完成对于进程信号屏蔽字的设置;
- how表示设置信号屏蔽字的方式:
- SIG_BLOCK:将set加入到当前的信号屏蔽字集合中, block = block | set;
- SIG_UNBLOCK:将set从当前的信号屏蔽字集合中去除,block = block & ~set;
- SIG_SETMASK:将set设置为当前的信号屏蔽字集合, block = block = set;
信号处理程序规范
信号处理是类Unix系统编程中比较棘手的一个问题,其需要面对几个问题:
- 信号处理程序与主程序并发运行,容易导致对于共享资源的竞争访问。
- 对于信号**“发生”和”递送“**的规则不明确,容易导致进去信号处理的误区。
- 不同的系统具有不同的信号处理语义。
下面分别对应于上面几个问题,分别介绍编写安全、正确和可移植的信号处理程序的基本规范。
安全的信号处理
信号处理程序和主程序通常是并发的运行的,由于它们共享相同的全局数据资源,比如全局变量,极有可能导致对于相同数据的并发访问,这很容易引入不可预知的问题,这类问题极难调试,因为系统行为飘忽不定,问题不是必现的。所以为了安全的编写信号处理程序,这里总结了几条保守的规则,用于避免此类问题。
处理程序要尽可能的简单
类似于硬件中断处理程序,要尽可能的简单一样,保持信号处理程序尽量的小和简单,同样是明智的。比如,信号处理程序中只是简单的设置一个标志,然后立刻返回,之后在主程序中处理循环检测该标志,进行后续的处理。
禁止调用异步信号不安全的函数
异步信号不全的函数,又称不可重入的函数。一般情况下,一个函数是不可重入的主要原因是:1)函数使用的静态的数据结构;2)函数调用了malloc和free函数族;3)函数直接或间接使用了标准I/O函数,比如,printf,标准I/O库函数的很多实现都使用了全局数据结构。
由于信号处理过程是异步的,所以为了系统安全,禁止使用异步信号不安全的函数,注意很多常见的函数,比如printf、sprinf、malloc、free都是异步信号不安全的函数。
Linux系统下,可以通过man 7 signal查询系统下所有的异步安全的函数,如下图所示:
在信号处理程序中唯一安全输出的方式就是使用write函数,下面的是基于write实现的简单、安全的I/O输出函数:
ssize_t sio_puts(char s[]) /* Put string */ { return write(STDOUT_FILENO, s, sio_strlen(s)); } ssize_t sio_putl(long v) /* Put long */ { char s[128]; sio_ltoa(v, s, 10); /* Based on K&R itoa(); return sio_puts(s); } void sio_error(char s[]) /* Put error message and exit */ { sio_puts(s); _exit(1); }
保存和恢复errno
许多异步信号安全的函数都会在出错返回时设置errno,比如read、write函数。如果在信号处理函数里调用了这样的函数,就可能会干扰主程序中其他依赖于errno的部分。解决方法是在进入信号处理函数时,保存errno到临时变量里,等到处理函数返回之前,恢复errno。
阻塞所有的信号
阻塞所有的信号的原因是为了保护对共享全局数据结构的访问。信号处理函数和主程序如果都存在对于共享数据结构的访问,那么在访问这些共享数据结构之前,需要阻塞所有的信号,以避免并发的访问这些数据结构。
用volatile声明全局变量
volatile限定符强迫编译器每次在代码中引入变量时,都要从内存中读取变量的值。
用sig_atomic_t声明标志
前面说了,信号处理函数要尽量的简单,一般只需要设置一个全局变量标志,C语言提供了sig_atomi_t这种数据类型来实现原子的访问全局数据标志。比如:
volatile sig_atomic_t flag;
对于变量flag的读写时安全的,不可中断,所以不需要访问期间不需要阻塞所有的信号,但是,需要注意的是,这里原子的访问,只适用于单个读写操作,flag=1,因为这些操作可以使用一条指令完成,但对于flag++或者flag=flag+1这种操作是不适用的,因为它们可能需要更多条的指令。
正确的信号处理
信号的一个与直觉不符的方面是未处理的信号是不排队的。因为pending位向量中每种类型的信号只对应一位,所以每种类型最多只能有一个未处理的信号。举个例子,对于信号s,如果当前进程正在执行信号s处理程序,这时如果发送了两个s信号到这个进程,那么只有先到的信号被列为**“未决的”,后来的信号就直接丢弃了。所以,在设计信号相关的处理程序时,需要记住“未处理的信号是不排队的”这条规则。关键思想时:如果存在一个未处理的信号就表明至少**有一个信号达到了,那么,后续的处理就是在信号处理函数中进行小心的设计,发现所有的信号带来的变化,然后,全部处理掉。
可移植的信号处理
Unix信号处理的另一个缺陷在于不同的系统有不同的信号处理语义。比如:
- signal函数的语义各有不同,前文说过,由于历史原因,signal存在两点缺陷:需要重复调用和信号丢失;
- 系统调用函数可能被中断,Unix系统调用可以分为快速和慢速两种系统调用,像read、write、select这些系统调用可能会导致进程阻塞的系统调用称为,慢速系统调用。如果信号中断了这些慢速系统调用,不同版本的Unix系统处理方式是不同,有的系统,如果慢速系统调用被中断,在其恢复之后,会立即返回,并设置errno位EINTR,而有些系统会自动重启中断了的系统调用代码。
为了解决这些问题,POSIX定义了sigaction函数,它允许用户在设置信号处理程序时,明确指定他们想要的信号处理语义。sigaction的函数原型如下:
#include<signal.h> int sigaction(int signo, const struct sigaction *restrict act, struct sigaction *restrict oact); 返回值:成功返回0,错误返回-1;
此函数中的act、oact和sigpromask中的set、oset的用法相同。struct sigaction的定义如下:
struct sigaction { void (*sa_handler) (int);/*信号处理函数地址,或SIG_IGN或SIG_DFL*/ sigset_t sa_mask; /*信号处理过程中,需要阻塞的信号集合*/ int sa_flags; /*信号选项*/ void (*sa_sigaction)(int siginfo_t *,void *); };
由于sigaction的使用比较复杂,这里可以将其封装成Signal函数,其调用方式与signal相同。
typedef void *Sigfunc(int); Sigfunc *Signal(int signum, Sigfunc *handler) { struct sigaction action, old_action; action.sa_handler = handler; sigemptyset(&action.sa_mask); action.sa_flags |= SA_RESTART; if (sigaction(signum, &action, &old_action) < 0) unix_error("Signal error"); return (old_action.sa_handler); }
Signal的信号处理语义如下:
- 只有这个信号处理程序正在处理的那种类型的信号被阻塞;
- 未处理的信号是不排队的;
- 只要可能,被中断的系统调用会自动重启;
- 一旦设置了信号处理程序,它会一直保持。
为了保持一致的信号处理语音,强烈建议,将Signal包装函数替换掉之前的signal函数。