信号处理
UNIX操作系统将信号作为通知进程发生了某种事件的一种手段,这种事件需要提请进程注意,并且其发生常常与进程当前的活动无关。信号也称为软中断,它提供了一种处理异步事件的方法,多数应用程序都需要用到它。例如,用户在终端按下Ctrl-c键会产生一个中断信号(SIGINT)并终止当前运行的程序。信号也可用于进程之间的通信和实现同步原语。本章我们先介绍信号有关的基本概念,然后着重讲述各种信号的用法和编程方法。
信号概念
信号是异步传送给进程的一种事件通知,进程无法准确地预测何时会出现信号。产生信号的原因有多种,例如:
1)用户按下了某个终止键,如Ctrl-\或Ctrl-c等。在这种情况下,终端驱动程序会向当前进程发送一个中断信号(SIGINT)来停止正在运行的程序。
2)程序异常,如零作除数、非法存储访问等。这种情况一般由硬件检测出,但由内核向发生异常的那个进程发送相应的信号。例如,SIGSEGV是对执行了非法存储访问的进程生成的信号。
3)kill函数允许进程发送任何信号给其他进程或进程组。
4)当发生了某种必须让进程知道的事件时也会生成信号。这些事件不是由硬件而是由软条件产生的。例如,当网络连接上到达了带外数据(12.8节)时生成的SIGURG,当进程设置的定时器到期时生成的SIGALRM,以及当进程往一个管道(11.1节)写数据,而此管道已不存在读数据方时生成的SIGPIPE等。
5)企图读写终端的后台进程会得到作业控制信号SIGTTIN或SIGTTOU。
6)当进程超越了CPU或文件大小的限制时,内核会生成一个信号。
生成信号的事件可以归为三大类:程序错误、外部事件和显式请求。例如,前面列出的第2)种和第6)种情况为程序错误,第3)种情况为显式请求,其他的均为外部事件。
信号的生成既可以是同步的,也可以是异步的。同步信号与程序中的某个具体操作相关并且在那个操作进行的同时产生。多数程序错误生成的信号是同步的。由进程显式请求而生成的给自己的信号也是同步的。
异步信号是进程之外的事件生成的信号。一般外部事件总是异步地生成信号。异步信号可在进程运行中的任意时刻产生,进程无法预期信号到达的时刻,它所能做的只是告诉内核假如有信号生成时应当采取什么行动。
无论是同步还是异步信号,当信号发生时,进程可以采取如下三种动作中的任意一种:
1)忽略信号。大部分信号都可以忽略,但SIGSTOP和SIGKILL除外,这两个信号决不会被忽略。不能忽略这两个信号的原因是为了给超级用户提供杀掉或停止任何进程的一种手段。
2)捕获信号。捕获信号即对信号进行专门的处理。此时要告诉UNIX内核,当信号出现时调用进行专门处理的函数。这个函数称为信号捕获函数,也称为信号句柄或简称句柄。例如,假如我们编写的是一个命令解释程序,就会需要处理用户键盘生成的中断信号,并在接收到这种信号时中断正在为用户执行的命令并返回到程序的主循环准备接收下一命令。
3)执行系统默认动作。系统为每种信号规定了一个默认动作,这个动作由UNIX内核来完成。有五种可能的默认动作:
流产:终止进程并且生成内存转储文件,即写出进程的地址空间内容和寄存器上下文至进程当前目录下名为core的文件中。
终止:终止进程但不生成core文件。
忽略:忽略信号。
挂起:暂停进程。
继续:若进程被暂停,恢复进程执行,否则忽略此信号。
进程可以在任何时候为信号指定一个新动作或恢复其默认动作。对于每一种信号,在某个时刻它对应的动作安排称为该信号的布局。
UNIX信号处理过程实际上涉及两个方面—生成和交付。信号生成发生在出现了一个需要进程注意的事件时,此时内核将检查接收信号进程的有关数据结构,此结构中记录了信号的当前布局、悬挂信号集和处理动作等内容。如果信号是要被忽略的,内核不做任何动作便返回;若不是忽略的,内核则把此信号加到悬挂集合中。通常UNIX表示悬挂信号集的数据结构是一个位串,其中每一位对应一个信号,内核无法记录同一信号的多个实例,因此进程只知道至少有一个该信号的实例正悬挂着。
如果进程正处在可中断的睡眠状态并且这个信号是非阻塞的,内核便唤醒此进程使它能够接收信号。此外,作业控制信号,如SIGSTOP或SIGCONT将直接挂起或恢复进程而不是发送出去。
被唤醒的这个进程一旦运行便在返回到它的正常用户态之前先处理所有悬挂信号。它让内核代表它查看是否有悬挂信号,当有悬挂信号并且当前没有被阻塞时,内核将查看与该信号有关的信息。如果没有指定句柄,它将采取默认动作,通常是终止进程。如果有句柄,它将把此信号加入到阻塞信号屏蔽中;如果对该信号指明了SA_NODEFER标志,则不把它加到信号屏蔽中。类似地,如果指明了SA_RESETHAND标志,则恢复该信号的动作为SIG_DFL(关于SA_NODEFER和SA_RESETHAND标志,参见7.4.4节)。
最后,内核安排进程返回到用户态并传递控制给信号句柄,同时保证当句柄完成时,进程将从被信号中断处的代码开始执行。
由异步事件生成的信号可能在进程代码执行路径的任何一条指令之后发生。当信号句柄完成时,进程从它被信号中断之处恢复执行。如果信号是在进程正处在系统调用期间到达的,内核通常流产此系统调用并返回错误EINTR(7.9.3节)。
本章中,我们经常会涉及一些与信号有关的术语。当发生了一个需要引起进程注意的事件而导致信号出现时,我们称对此进程生成信号或发送信号。这些事件可以是硬件异常,软条件、终端产生的信号或调用kill()函数产生的信号。当信号生成时,内核通常在进程表中设置某种形式的标志。
当被发送信号的那个进程识别到了信号并采取了适当动作时,我们称信号被交付给了进程,或称进程接收了信号。若信号交付时进程执行信号句柄,我们称进程捕获了信号。当一个信号已经生成,但还未交付时,称该信号是悬挂的。
进程接收一个信号时的动作取决于该信号的当前布局和进程的信号屏蔽字。每个进程有一个信号屏蔽字标识进程当前被阻塞交付的信号集合,它是一个位串,其中每一位对应一个信号。如果某个信号对应的位被设置,则该信号是当前被阻塞的。
进程可以有选择地阻塞信号的交付。阻塞信号与忽略信号完全不同,忽略信号是不理睬发生的信号,而阻塞信号只是暂时地推迟信号的交付,直到我们准备好了时再对它进行处理。当一个被阻塞的信号生成时,如果进程为该信号指定的动作是默认动作或捕获它,则此信号将一直悬挂直到对此信号的阻塞被放开,或者改变该信号的动作为忽略。系统对怎样处理阻塞信号的判定是在信号被交付时而不是信号生成时,这样便可以允许进程在信号被交付之前改变信号的动作。
UNIX 信号
UNIX系统为每一种可能的事件定义了一种信号,每种信号有一个信号数,每一个信号数对应一个以字母SIG开头的信号名。例如,SIGABRT是进程生成的流产信号,SIGALRM是由定时器到期所生成的闹钟信号。
函数psignal()可以打印出信号数对应的描述信息至标准错误输出流。
#include <signal>
void psignal (int signo, const char *msg);
若msg是空指针,psignal()只打印signo对应的描述并后随一换行符。
若msg是非空指针,psignal()用这个字符串(通常为程序名)作为其输出消息的前缀,并且在此前缀和signo对应的消息之间放置一个冒号和空格将它们分开。
程序错误类信号
程序错误一般指程序执行了某种不适合的动作。并不是所有程序错误都会生成信号,事实上大部分错误都不会生成信号。例如,打开一个不存在的文件是一个错误,但它不产生信号,而是返回-1指出错误。这里的程序错误仅仅是指那些会导致产生信号的错误。
当操作系统或计算机硬件检测到一个严重的程序错误时会生成下述信号。一般而言,这些信号表明程序已经以某种方式严重地崩溃而无法正常继续运行。
所有这些信号的默认动作都是使进程流产,即在终止进程的同时写出内存信息转储文件core。这样用户便可通过调试器来查出引起错误的原因。
SIGABRT:调用abort()函数生成的流产信号。
SIGFPE:致命算术异常。此信号名源于浮点异常(floating point exception),实际上它涵盖了所有算术异常,包括零作除数和浮点溢出。
SIGILL:进程执行了一条非法指令或企图执行特权指令。此信号名由非法指令(illegal instruction)而来。经编译器生成的执行文件只含合法指令,一般出现SIGILL信号的原因是可执行文件被破坏或者程序企图执行数据。
SIGBUS:实现定义的硬件错误,其名字来源于总线错误(bus error),它一般指硬件地址错。SIGBUS信号与SIGSEGV信号的区别在于SIGSEGV指出的是对合法存储地址的非法访问(如访问不属于自己的存储空间或只读存储空间),而SIGBUS指的是硬件地址非法。
SIGIOT:实现定义的硬件错误,其名字来源于PDP-11输入输出自陷指令iot。
SIGSEGV:非法存储访问,其名字源于段访问错误(segmentation violation)。当进程企图读写分配给它的存储区之外的单元或写只读存储区时产生这个信号(实际上,仅当所访问的单元偏离程序的存储区相当远而使得系统的存储保护机制能检测出时才发生此信号)。导致SIGSEGV信号的原因常常是由于引用了一个空的或未赋初值的指针,或由于数组越界而引起。
SIGSYS:非法系统调用。由于某种未弄清的原因,进程执行了一条被内核认为是系统调用的机器指令,但随该指令一起指明系统调用类型的参数不合法。
SIGTRAP:实现定义的硬件错误,其名字源于PDP-11的TRAP指令。该信号一般由机器的断点指令(也可能还有其他自陷指令)产生,调试器常使用此信号。
程序中止类信号
程序中止类信号用于终止进程的运行。这类信号之所以有不同的名字是因为生成它们的方式和使用的目的稍有不同,并且程序可能需要不同地处理它们。
所有这类信号的默认动作是使进程终止运行。但为了在终止进程之前能够做一些清场的工作,例如,保存状态信息、删除临时文件或恢复终端原来的方式,常常需要处理这些信号。这类信号的捕获函数应当在处理结束时为这个信号指定默认动作,然后再次生成该信号,从而使得进程以该信号的默认动作而终止,就好像没有经过信号处理函数一样。
SIGHUP:终端驱动程序检测到连接断开时会生成这个信号给与控制终端相连的控制进程。连接断开可能是由于网络或调制解调器的连线中断而引起。不过在这种情况下,仅当终端的CLOCAL标志没有设置时才生成此信号(终端的CLOCAL标志设置意味着所连终端是本地终端,此时终端驱动程序忽略所有调制解调器状态线。9.4.3节将描述这个标志)。注意,接收这个信号的控制进程可以是后台进程,这一点不同于终端生成的其他信号(中断、结束和暂停),终端生成的其他信号总是交付给前台进程组。当控制进程终止时也会生成这个信号给与控制进程相连的所有进程。
SIGINT:当按下中断键(常常为Delete或Ctrl-c)时由终端驱动程序生成的信号。此信号发送给前台进程组中的所有进程。这个信号常常用于终止正运行着的程序,特别是当它在屏幕上生成了许多输出而我们不再想看时。
SIGKILL:无条件立即终止程序的运行。这个信号一般只由显式请求生成,它不能被捕获或忽略,也不能被阻塞,因此它总是致命的。
SIGQUIT:当在终端按下结束键(常为Ctrl-\)时,终端驱动程序生成此信号。它被发送给前台进程组中的所有进程。这个信号在终止前台进程的同时也生成core文件。
SIGTERM:使程序终止运行,此信号通常由kill(1)命令发出。与SIGKILL不同,该信号可以被阻塞、捕获和忽略。这是要求程序终止的常规信号。
闹钟类信号
闹钟类信号用于通告定时器(8.4.2节)到期。这类信号的默认动作是使程序终止,但很少使用,多数使用此类信号的程序都捕获它们。
SIGALRM:指出一个度量真实时间或时钟的定时器到期,例如,当用alarm()设置的墙钟定时器到期时会生成这个信号。
SIGPROF:指出剖面间隔定时器到期。
SIGVTALRM:指出虚拟间隔定时器到期,其名字是virtual time alarm的缩写。
I/O类信号
I/O类信号用于通知进程在描述字上发生了感兴趣的事,用于支持信号驱动的I/O。为生成这种信号,我们必须调用ioctl()或fcntl()要求特定的文件描述字生成它们(10.2节)。这类信号的默认动作是忽略它们。
SIGIO:当文件描述字可以执行输入或输出时发送此信号。一般只有终端和网络连接套接字是唯一能够生成此信号的文件。
SIGPOLL:Linux中,SIGPOLL等价于SIGIO。
SIGURG:此信号通知进程出现了紧急事件。当在网络连接上收到带外数据时(12.8节)可以有选择地生成此信号。
生成信号
UNIX系统中产生信号的原因多种多样,除了硬件自陷、程序错误生成的信号之外,用户也可通过终端控制字符和shell命令异步地向进程发送信号。例如,当我们希望中断一个正在运行的程序时,可直接按Ctrl-c或Ctrl-\,前者通常发送一个SIGINT信号,后者通常发送一个SIGQUIT信号。用shell命令“stty-a”可以查出其他可生成终端信号的控制字符。shell命令kill(1)也可用来杀死正在运行的进程或向进程发送信号。例如,命令
$ kill -9 2056
杀死进程ID为2056的进程,其中9为SIGKILL对应的信号数。而命令
$ kill -SIGUSR1 2058
则向进程ID为2058的进程发送信号SIGUSR1。
我们也可以在程序中通过调用生成信号的函数给自己或向其他进程发送信号。常用于生成信号的函数除了已经介绍过的abort()、alarm()外,还有raise()和kill()。
raise()函数
#inlcude <signal.h>
int raise(int sig);
raise()简单地发送信号sig给调用它的进程。如果安装了信号sig的句柄函数,则raise()只有在句柄函数返回时才会返回。
raise()常用在句柄函数内重新生成刚刚捕获的这个信号,以便进程以信号的默认动作终止运行。我们在7.5.2节将看到应用它的例子。
raise()是由ANSI C定义的,ANSI C中不涉及多进程的概念,因此它只能给自己发送信号。如果希望向其他进程发送信号,则要使用kill()。
kill()函数
kill()发送信号给一个进程或一组进程。
#inlcude <signal.h>
int kill(pid_t pid, int sig);
参数sig指明要发送的信号,它可以是任意信号,也可以是0(空信号)。如果sig为0,kill()只进行正常的错误检查而不实际发送信号,这常常用来检查pid的合法性。例如,当我们想要查看某个进程是否仍然存在时,可向这个进程发送一个空信号。如果该进程不存在,kill()将返回-1并置errno为ESRCH。
参数pid指明信号要发送给哪一个进程或者进程组。它有如下四种取值情况,其中第一种(pid>0)用于向某一个进程发送信号,其余三种用于向一组进程发送信号。
pid > 0:信号发送给进程ID为pid的进程。
pid == 0:信号发送给进程所在进程组的所有进程。
pid < -1:信号发送给进程组ID等于pid绝对值,且发送者有权向它发送信号的所有进程。
pid == -1:广播信号,即发送信号给发送者有权向它发送该信号的所有进程。
为了防止如随意杀掉属于其他用户的进程之类的恶意行为,用kill()向进程或进程组发送信号时必须满足许可权限制。进程是否有权发送信号给另一个进程是由这两个进程的用户ID决定的。一般地,发送信号进程的实际用户ID或有效用户ID必须与接收信号进程的实际用户ID或有效用户ID相同。否则,发送信号进程的有效用户ID必须是超级用户。超级用户可以向任何进程发送信号。
对于这种发送信号许可权的检查有一个例外情况,这就是如果被发送的信号是SIGCONT,进程可以向与它属于同一会晤期的其他所有进程发送此信号。
当kill()给进程自己发送信号时(例如,kill(getpid(), sig)),如果此信号未受阻,则发送进程在kill()返回之前至少会接收到一个信号,这个信号要么是sig,要么是某个另外悬挂着的、未阻塞的信号。
如果信号sig能成功发送给由pid指定的任意一个进程,则kill()成功,其返回值为0;否则kill()失败,此时无信号被发送,其返回值为-1且设置errno指出错误原因。当pid指定的是进程组且kill()成功时,我们知道的只是它已成功地发出了信号,但并不能区分究竟是其中一个进程得到了该信号还是它们都得到了该信号。
例7-1 程序7-1是使用kill()发送信号的例子。其中父进程派生一个子进程,然后等待子进程开始工作。子进程一旦开始工作便调用kill()向父进程发送一个SIGUSR1信号通知父进程。为了捕获子进程发送的信号,父进程需要调用signal()安装信号捕获函数。父进程的捕获函数是sig_usr(),它简单地通过设置一个标志来通知信号已经到达。
volatile sig_atomic_t usr_interrupt = 0;
void sig_usr(int sig) {
usr_interrupt = 1;
}
void child_fuction() {
printf("I'm here! My pid is %d.\n", (int)getpid());
kill(getppid(), SIGUSR1);
puts("Bye, now...");
exit(0);
}
int main() {
signal(SIGUSR1, sig_usr);
pid_t pid;
if ((pid = forkI()) < 0)
exit(1);
if (pid == 0)
child_fuction();
while (!usr_interrupt );
puts("That's all");
exit(0);
}
设置信号的动作
对于每一种信号,进程可以指定要么忽略它,要么采取默认动作,要么捕获它;进程也可以在任何时候对一个信号重新指定新动作或回到其原先的动作。函数signal()和sigaction()用于完成这种指定。
signal()函数
#inlcude <signal.h>
typedef void (*sighandler_t) (int);
sighandler_t signal(int signum, sighandler_t handler);
参数signum指明是哪一种信号,handler指明信号发生时采取的动作,它可以取下述三种值之一:
SIG_DFL:采用默认动作。
SIG_IGN:忽略该信号,但SIGKILL和SIGSTOP除外,系统不允许忽略这两个信号。我们通常不应当忽略那些表示严重事件或请求终止的信号。忽略严重事件,如SIGSEGV这样的程序错误信号对程序继续执行不会有意义。忽略诸如SIGINT、SIGQUIT和SIGTSTP之类的用户请求是不礼貌的。
信号句柄:信号句柄是当信号出现时要调用的函数,也称为信号捕获函数。指定信号句柄表明当信号生成时应当调用此句柄对信号进行处理。我们称以此方式调用signal()为建立信号signum的句柄。
当信号发生时,如果建立了信号句柄,系统在把控制转到信号句柄之前将阻塞后继新的信号直至信号句柄完成为止,或者首先改变该信号的动作为SIG_DEL(相当于再调用一次signal(signum,SIG_DFL))。究竟是哪一种做法与具体实现有关。BSD兼容的系统采用前一种方法,系统V兼容的系统则采用后一种方法。Linux系统默认情况下采用与BSD兼容的做法,当设置了特征测试宏_XOPEN_SOURCE时,则采用与系统V兼容的做法。
由signal()建立的信号句柄应当是一个仅有一个参数且无返回值的函数,其参数是一个指明信号的整数。因此signal()的第二个参数的类型是sighandler_t类型,这个类型表示的是一个指向有一个整型参数但无返回值的函数的指针,即指向信号句柄的指针。
signal()的返回值是指向信号sig前一次有效动作的指针,因此它的原型与第二个参数相同。当signal()调用成功时,返回值要么是SIG_DFL或SIG_IGN,要么是信号句柄指针。我们可以保存这个返回值并且在以后用它作为signal()的第二个参数再次调用signal(),从而恢复信号至原来的动作。注意,这里的“前一次有效动作”指的并不一定是前一次用signal()设置的动作。当前一次指定的动作是捕获信号并且执行了信号句柄时,如果系统是系统V兼容的,则返回的动作是SIG_DFL。
signal()的返回值是指向信号sig前一次有效动作的指针,因此它的原型与第二个参数相同。当signal()调用成功时,返回值要么是SIG_DFL或SIG_IGN,要么是信号句柄指针。我们可以保存这个返回值并且在以后用它作为signal()的第二个参数再次调用signal(),从而恢复信号至原来的动作。注意,这里的“前一次有效动作”指的并不一定是前一次用signal()设置的动作。当前一次指定的动作是捕获信号并且执行了信号句柄时,如果系统是系统V兼容的,则返回的动作是SIG_DFL。
如果signal()调用出错,它返回SIG_ERR并设置errno。唯一的错误码是EINVAL,即signum给出的信号数非法。非法的原因可能是signum不是一个信号数,企图忽略SIGKILL或SIGSTOP,或者企图为SIGKILL和SIGSTOP建立信号句柄。
例7-2 程序7-2是使用signal()设置信号句柄的示例程序。其中函数sig_usr()是对信号SIGUSR1进行处理的函数。主程序通过调用signal()建立这个信号的句柄为此函数。for循环中的pause()函数(7.7.1节)悬挂进程直至有信号出现为止。
static void sig_usr (int );
int main() {
if (signal(SIGUSR1, sig_usr) == SIG_ERR)
exit(1);
}
我们在Linux中用两种方式来编译该程序,然后在后台运行它并用kill(1)命令向它发SIGUSR1信号。
进程初启时的信号动作
进程有两种创建方法:用fork()派生的子进程或者由exec()装载的新进程(严格说来是执行程序)。用fork()派生的子进程总是继承父进程的信号动作,这包括信号屏蔽(7.6.2节)、捕获函数,以及相关的标志等。但是,当进程是由exec()加载的,则除调用exec()的这个进程已设置为要忽略的信号之外,其他信号都将设置为默认动作。实际上,exec()将所有已设置为要捕获的信号的动作改变为默认动作,而让其他信号保持不变。我们仔细想一下就会发现这样规定自有其道理。因为exec()加载的是一个新程序,原程序中的句柄函数已经不存在,因此它只能将这些原来要被捕获的信号设置为默认动作。
所有应用程序都是shell通过exec()系统调用加载运行的。因此,在程序开始执行时,信号要么是被忽略的(继承shell的设置),要么是默认动作。
在支持作业控制的shell上,当我们在后台执行一个进程,例如:
$gcc foo.c&
shell会自动地将后台进程的中断和结束信号(SIGINT和SIGQUIT)的动作设置为忽略它们。这就是为什么我们按下中断键对后台进程不起作用的原因。如果不这样的话,则会导致按下中断字符时不仅仅终止前台进程,而且也会终止所有后台进程。
因此,如果程序需要捕获这两个信号,为了防止它在作为后台进程运行时接收这两个信号,应当这样来建立它们的句柄:
void termination_handler(int );
if(signal(SIGINT, SIG_IGN) != SIG_IGN)
signal(SIGINT, termination_handler);
if(signal(SIGQUIT, SIG_IGN) != SIG_IGN)
signal(SIGQUIT, termination_handler);
其中,if条件内调用signal(SIGQUIT,SIG_IGN)只是为了获得当前的信号设置。这样,仅当这两个信号的当前动作不是忽略时才设置它的捕获函数。当查询的结果发现信号动作是SIG_IGN时,这个调用保持与原来的设置一致。之所以这样编程是因为signal()在系统V UNIX中无法只查询信号设置而不改变信号设置。从这几行代码可以看出老式signal()的一个局限性:为了查看信号的当前动作,我们无法保证不改变它。此外,对于与系统V兼容的signal()而言,还存在着另一个重要的问题,即所谓的不可靠性。
不可靠信号
早期的UNIX版本只提供了signal()系统调用来支持信号。使用老式signal(),每当信号被交付时,信号动作总是由系统自动地重置为默认动作。在程序7-2中我们已看到,当特征测试宏_XOPEN_SOURCE起作用时,Linux的signal()采用的就是这种老式信号机制。因此,在这种情况下使用signal()时,为了使得在信号句柄执行期间仍能对同一信号的后继出现作出反应,往往需要在句柄的第一条语句中再次调用signal(),如下所示:
catch_sigquit() {
signal(SIGQUIT, catch_sigquit);
...
}
main() {
signal(SIGQUIT, catch_sigquit);
...
}
我们知道,在程序执行路径的任意一点上,信号随时可能发生。假如后续的SIGQUIT碰巧在第2行出现(即已经进入catch_sigquit(),但还未执行signal()调用。此处尽管是我们故意留的空行,但在执行码中,函数入口与signal()调用之间存在一些真实的与函数入口有关的代码),则将导致进程终止并生成core文件。不过,这并不是编程错误,而是由于时机问题。signal()调用使得系统在将控制转交给信号句柄之前重置该信号的动作为默认动作,而SIGQUIT的默认动作是终止并写出core文件。因此,如果用户非常快地连按两次quit键,则第一个键导致SIGQUIT信号动作回到SIG_DFL并调用句柄,假设第二个键引起的信号恰巧在catch_sigquit()运行到第二行时到达,于是系统将采取默认动作而流产进程。我们称在catch_sigquit()入口和第3行signal()调用之间存在着一个时间窗,因为它是否出现与时机有关,并且我们无法关闭它而使问题不存在。尽管这种问题极少出现,可能用这个例子在机器上试运行上千次都不会出现,但它却是真实存在的,说不准在什么时候,时机碰巧它便会发作。这种问题无法用调试器查出,因此很难检测出来。因此称系统V的这种老式signal()为不可靠信号。
这种老式signal()系统调用的问题在于,它能做的只是要么忽略一个信号,要么捕获一个信号。BSD扩充了signal()系统调用,使它还可以阻塞新到达的信号从而避免了时间窗问题。本书的例子中如无特别说明,假定使用的都是Linux默认情形下的signal(),即与BSD兼容的signal(),因此不存在不可靠的问题。
sigaction()函数
为了克服早期signal()系统调用的不足,POSIX定义了另外一个函数sigaction(),它使用一个称为sigaction的结构,该结构中除定义了信号交付采取的动作外,还包含了其他一些控制信息。sigaction()提供的信号机制比老式的signal()更可靠,能力也更强,一般我们应该使用sigaction()。
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oact);
sigaction()用来检测或指定与特定信号有关的动作。
参数signum指定信号,它可以是除SIGKILL和SIGSTOP之外的任何信号。
参数act和oact均为指向sigaction结构对象的指针。如果act不为NULL,则指向规定信号动作的一个结构;如果该参数为NULL,则不改变信号的动作,但可用来查询此信号的当前动作。如果参数oact不是NULL,系统将返回该信号先前的动作于oact所指的对象。当需要在以后使信号动作恢复到原先的状态时,常常指定该参数。另外,指定它也使得我们能够通过查询信号的当前动作判别是否应当重置其动作。
sigaction()调用成功返回0,失败则返回-1且不安装信号的新动作。
类型sigaction是描述信号动作的一个结构,其定义为:
struct sigaction{
void (*sa_handler) ();
void (*sa_sigaction) (int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
}
其中:
1)sa_handler与signal()的第二个参数handler相同,它指定与信号相连的动作,其值可以是SIG_DFL、SIG_IGN或指向一个信号句柄。
2)sa_sigaction指定一个信号句柄地址,仅当sa_flags中设置了SA_SIGINFO标志(7.10节)时它才起作用;当未设置SA_SIGINFO标志时,sa_handler起作用。
3)sa_mask指明信号句柄执行期间要阻塞的一组信号(7.6节)。如果指定了信号句柄,则当信号被交付时,这组信号在句柄入口处被加到进程的信号屏蔽(7.6.2节)中。此外,若未在sa_flags中指定SA_NODEFER标志,还将自动阻塞导致信号句柄执行的这个信号。当信号句柄正常返回时,进程的信号屏蔽将恢复成原先的状态。
4)sigset_t是表示信号集的类型,7.6.1节将作介绍。
5)sa_flags是指导内核对信号交付时所采取的动作进行进一步控制的若干标志。它是一个位串,我们应当用“或”运算符(C语言中的“|”)将所选择的标志拼在一起,然后存储结果至此域。sa_flags中可以设置如下标志:
SA_NOCLDSTOP——此标志只对SIGCHLD信号有效。正常情况下,子进程被停止(即接收到SIGSTOP、SIGTSTP、SIGTTIN或SIGTTOU之一)时,总是会向父进程发送SIGCHLD信号。已被停止的子进程恢复运行时(SIGCONT),也可能向父进程发送SIGCHLD信号。但如果设置此位且signum参数是SIGCHLD,则在这两种情况下都不给调用进程发送信号。注意,这个标志并不影响子进程终止时发送SIGCHLD信号。对非SIGCHID信号设置此标志不起作用。
SA_RESTART——如果设置该标志并且捕获信号,系统将在信号句柄返回时自动恢复被该信号中断了的系统调用(7.9.3节);否则被中断的系统调用将返回-1并置errno为EINTR。多数情况下sa_flags的值为SA_RESTART。
…
一旦用sigaction()为特定信号建立了动作,该动作就一直保持,直到另一次调用sigaction()建立另一个动作为止,或者直到调用了某个exec()函数为止,或者因设置了SA_RESETHAND标志导致系统自动改变其动作为默认为止。
sigaction()比signal()的能力要强,看起来也要复杂些。但实际上对于多数应用,它的使用都比较简单。在开始时不必拘泥于sa_flags标志的细节,主要应注意掌握以下两点:
首先,同signal()一样,sigaction()也用于指定信号的动作。但与signal()不同的是,除非明显地用sa_flags指明SA_RESETHAND标志,否则用sigaction()安装的信号句柄将一直保持安装,直到下一次调用sigaction()为这个信号重新安装动作或调用了exec()为止。sigaction()的这种机制消除了老式signal()用法可能产生的时间窗。
其次,当信号句柄执行时,所捕获的这个信号是自动阻塞的。另外,还可以在sigaction()安装句柄时用sa_mask指明信号句柄执行期间要阻塞的其他一些信号。当句柄执行时系统会自动地将这些信号加到信号屏蔽中。用这种方法可以保证当我们正在处理给定信号时不会被不希望的信号所中断。当句柄返回时,这些被阻塞的信号将自动地从信号屏蔽中去掉,从而恢复信号屏蔽在运行句柄之前所具有的值。7.6节将讨论阻塞信号。
下面的例子说明了用sigaction()建立信号句柄的一般方法。
static void handler (int signum)
int main() {
struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
if (sigaction(SIGINT, &sa, NULL) == -1) {
...
}
...
}
调用sigaction()之前必须给第二个参数的各个成员赋值。在不需要对信号交付进行复杂控制的多数情况下,除了设置成员sa_handler外,一般只设置标志SA_RESTART和置sa_mask为空集。由于成员sa_mask是一个信号集合,因此要用专门的置信号集合为空集的函数sigemptyset()。
例7-3 程序7-3是用sigaction()实现的signal(),它既保留了signal()编程接口,又实现了可靠信号。该函数对除SIGALRM之外的所有信号均设置SA_RESTART标志,以便使被这些信号中断的系统调用(7.9.3节)均自动重新开始。不对SIGALRM设置该标志是因为SIGALRM信号常常用来为I/O操作设置时间限。在程序7-14中将见到这样做的原因。
void (*signal(int sig, void (*handler) (int))) (int) {
struct sigaction act, oact;
if (handler == SIG_ERR || sig < 1 || sig >= NSIG) {
errno = EINVAL;
return SIG_ERR;
}
act.sa_handler = handler;
if (sigemptyset(&act.sa_mask) < 0)
return SIG_ERR;
act.sa_flags = 0;
if (sig != SIGALRM)
act.saflags |= SA_RESTART;
if (sigaction(sig, &act, &oact) < 0)
return SIG_ERR;
return oact.sa_handler;
}
信号句柄
信号句柄就是信号捕获函数,它是程序的一部分。但与普通函数不同的是,程序不明显地调用它们,而是通过调用signal()或sigaction()来告诉操作系统当信号到达时调用它,这称之为建立句柄。
在编写信号句柄时,可以使用两种基本的策略:
在句柄函数中只做简单的处理,如修改某个全局数据结构或设置某个全局标志,然后正常返回。
在句柄函数中对信号进行适当的处理,如做清理现场、删除临时文件等工作,然后让句柄终止程序运行,或者将控制转到程序中能从导致信号的情形中恢复的某处而执行。
正常返回的信号句柄
正常返回的句柄一般用于这类信号:如SIGALRM、I/O信号以及进程间通信的信号。另外,SIGINT信号在设置一个标志告诉程序在方便的时候再退出之后也可以正常返回。
正常返回的信号句柄常常通过修改某个全局变量来起作用。程序正常执行过程中则通过考察这个全局变量来感知信号。这种全局变量应当是sig_atomic_t类型,它表示一种不会被信号中断的数据。我们将在原子数据一节(7.9.4节)进一步描述这种类型。
例7-4 程序7-4是一个使用正常返回信号句柄的例子。
volatile sig_atomic_t keep_going = 1;
void catch_alarm(int sig) {
keep_going = 0;
puts("catched alarm");
}
void do_stuff() {
puts("Doing work while wait for alarm ...");
}
int main() {
signal(SIGALRM, catch_alarm);
alarm(2);
while(keep_going) {
do_stuff();
}
puts("SUCCESS");
return 0;
}
这里,变量keep_going被声明成sig_atomic_t类型,volatile修饰符告诉编译程序该变量之值可能会在别的某个地方被更改,这样编译器就不会对它进行不适当的优化。
主程序main()安装SIGALRM信号句柄函数后设置2秒的定时(8.4.2节),然后执行while循环直至定时器到期信号到达。当信号到达时,句柄函数catch_alarm()简单地置keep_going=0并输出信息后返回,这使得循环可以继续完成当前正在进行的工作之后才终止循环。这种技术常常是有用的,它可以使我们在恰当的时机而不是在信号到达的当时对到达的信号作出反应。这样,便可以从容地完成手头的工作。
终止进程的句柄
终止程序运行的信号句柄常常用于程序错误信号和交互中断信号。这种信号句柄通常在完成一些必要的清场工作或报告出错信息之后终止程序的运行。终止程序运行常用两种方法:直接调用exit()或abort()终止进程,或者通过设置信号默认动作再次生成同一信号终止进程。
下面的两个信号句柄框架说明了这两种方法。第一个信号句柄通过调用函数raise()再次生成同一种信号来自然终止进程的运行。
void fatal_error_signal(int sig) {
signal(sig, SIG_DFL);
raise(sig);
}
下面的代码在句柄函数内直接调用exit()正常退出。该函数作为SIGTERM信号的句柄,在终止一个游戏程序之前首先列出游戏结束清单,并清除屏幕。
void TerminateChess(int sig) {
ListGame();
gotoXY(1, 24);
fflush(stdout);
exit(EXIT_SUCCESS);
}
阻塞信号
阻塞信号意味着告诉操作系统保持该信号并推迟它的发送。被阻塞的信号不会丢失,它们只是暂时被悬挂,直到阻塞解除。阻塞信号是信号处理过程中常常要用到的一种动作,它提供了一种手段使我们可以防止程序中的关键代码被信号中断。例如,当我们想在信号到达之前完成某个任务,想要与信号句柄函数互斥地读写共享数据,或者想不受其他信号的干扰完成对某个信号的处理时,都会需要阻塞信号。
每个进程有一个信号屏蔽,它包含当前被阻塞的信号集合。进程可通过设置信号屏蔽来指明要阻塞或放开的信号。
sigset_t类型和信号集操作
信号屏蔽是被阻塞的信号集合,UNIX系统中表示信号集合的数据类型是sigset_t,它是一个位串,其中每一位对应系统支持的一种信号。在设置或查询信号屏蔽之前需要指定所操作的信号集合。sigset_t类型表示的信号集合只能用下述五个函数来操纵:
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
这5个函数中,参数set指向被操作的信号集,参数signo给出一个信号数。
函数sigemptyset()初始化set指向的信号集为空集,即不包含任何信号。sigfillset()则相反,它初始化set指向的信号集使其包含所有的信号。这两个函数总是返回0。程序在使用信号集即sigset_t类型的数据对象之前,必须调用这两个函数之一进行初始化。
一旦对信号集进行了初始化,便可以往其中加入或删除指定的信号。sigaddset()将信号signo加入到set所指的信号集;sigdelset()则将信号signo从set所指信号集中删除。这两个函数均在成功时返回0,失败时返回-1。失败原因是signo无效或是系统不支持的信号。
sigismember()测试信号signo是否属于set所指信号集的成员。若该信号在此信号集内,它返回1,否则返回0;若出现错误则返回-1。唯一的错误原因是signo非法。
注意,前面四个函数只是给set所指的sigset_t类型的数据对象赋值,它们并不实际阻塞或者放开任何信号。真正阻塞或放开信号需要调用另外的设置信号屏蔽的函数。
设置信号屏蔽
每个进程有一个信号屏蔽。进程在创建时继承其父进程的信号屏蔽,之后可以查看和改变进程的信号屏蔽。进程改变信号屏蔽有两种途径:调用sigaction()设置句柄中要阻塞的信号和调用sigprocmask()。sigaction()设置的阻塞信号只在信号句柄执行期间起作用,句柄退出时将自动恢复句柄调用之前的设置。sigprocmask()则可以在程序的任意点显式地阻塞或放开信号。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
sigprocmask()用来检测或改变(或两者兼而有之)调用进程的信号屏蔽。如果set不是NULL,它指向用于改变信号屏蔽的信号集,此时参数how的值指明如何改变信号屏蔽,它必须是下列值之一:
SIG_BLOCK:阻塞set所指信号集中的信号,即将它们加到当前信号屏蔽中。SIG_UNBLOCK:放开set所指信号集中的信号,即将它们从当前信号屏蔽中去除。
SIG_SETMASK:用set所指信号集作为进程的新信号屏蔽,抛弃原先的信号屏蔽值。
参数set的值是NULL时,参数how没有意义,并且不改变调用进程的信号屏蔽。最后一个参数oset用于返回进程原先的信号屏蔽。如果我们只是想改变信号屏蔽而不查看它,则传送一个空指针作为oset之值。如果只是想查看当前的信号屏蔽而不改变它,则传送一个空指针作为set之值。oset常常用于记住原先的信号屏蔽以便之后能恢复它。
等待信号
如果程序是由外部事件驱动的,或者使用信号来同步,则可能会需要等待信号到达。在程序7-1中,为了等待信号到达,我们用了一个循环来不断地测试信号句柄设置的标志。这种方法是忙等待,它一直占据着CPU运行时间做同一件事,显然这是一种浪费资源的方法。在通常的情况下,我们使用pause()和sigsuspend()来等待信号。用这两个函数,当进程在等待信号过程中,操作系统可以将CPU调度给其他进程使用。等待信号到达的最简单方法是调用pause(),然而使用sigsuspend()则更为可靠。
pause()函数
#include <unistd.h>
int pause();
pause()悬挂调用进程直至有信号到达。仅当这个信号导致执行句柄函数并且句柄函数返回,pause()调用才返回。此时,pause返回-1并置errno为EINTR。这被视为是不成功的返回,因为成功的行为是永久性地悬挂进程。在其他情况下pause()不返回,此时要么是信号导致程序终止,要么是句柄函数进行了非局部控制转移。
pause()的使用虽然简单,但它有时隐藏着严重的时间窗错误,这种错误会导致程序神秘地被悬挂。我们在程序7-2中曾使用过用pause(),在那个程序中,主要工作是由信号句柄来做的,主程序main()除了设置信号句柄和调用pause()之外不做其他事情。pause()的这种用法一般是安全的。每当发出一个信号时,句柄完成下一批要做的工作然后返回,因此程序的主循环能再一次调用pause()。
但是,不能用pause()来等待信号到达,之后再开始做真正的工作。这样做不安全,即使通过设置标志来安排信号句柄合作也仍然不能可靠地使用pause()。考虑下面这段代码:
volatile sig_atomic_t usr_interrupt = 0;
static void sig_usr(int signo) {
usr_interrupt = 1;
}
int main() {
signal(SIGUSR1, sig_int);
...
if (!usr_interrupt)
pause();
...
}
这段代码隐藏着一处错误:信号可能会在检查过usr_interrupt之后,但还未调用pause()之时到达。如果之后不再有其他信号出现,程序将永远被挂起。因此,对于复杂一些的程序,最好使用sigsuspend()来可靠地等待信号到达。
sigsuspend()函数
#include <signal.h>
int sigsuspend(const sigset_t *sigmask);
sigsuspend()用参数sigmask所指的信号集临时替代调用进程的信号屏蔽,然后挂起调用进程直到有不属于sigmask的信号到达为止。此信号的动作要么是执行信号句柄函数,要么是终止该进程。如果信号的动作是终止进程,sigsuspend()不返回;否则在信号句柄返回后,sigsuspend()返回并恢复进程的信号屏蔽为调用之前的值。
同pause()一样,因为sigsuspend()无限期地挂起进程的执行,所以没有表示成功完成的返回值。它总是返回-1并置errno为EINTR,这表示调用进程捕获到信号,并从信号句柄返回。但同pause()不一样,sigsuspend()与信号阻塞一起合作能够可靠地等待信号的到达。具体做法是:先阻塞要等待的信号,然后调用sigsuspend()临时放开该信号并等待信号句柄设置信号到达标志,如下面的代码所示:
本文详细介绍了UNIX操作系统中的信号处理机制,包括信号的概念、生成原因、交付流程以及信号的分类(程序错误、外部事件、显式请求)。重点讲解了信号的三种处理方式:忽略、捕获和默认动作,以及如何使用signal()和sigaction()函数来设置信号动作。同时,讨论了信号的同步和异步特性,以及阻塞信号在同步进程通信中的应用。

4263

被折叠的 条评论
为什么被折叠?



