Linux 信号

本文详细介绍了Linux系统的信号机制,包括信号的概念、产生、阻塞和捕捉。重点讲解了信号的产生条件,如终端操作、函数调用和软件条件。接着探讨了信号的阻塞,包括信号集操作函数、`sigprocmask`和`sigpending`函数的使用。此外,还深入分析了信号的捕捉,如`sigaction`函数和`pause`函数的运用。最后,文章提到了信号处理中的可重入函数概念和`sig_atomic_t`类型,以及处理竞态条件的`sigsuspend`函数。同时,强调了`SIGCHLD`信号在处理子进程结束时的作用。

Linux 信号

ref

信号的概念

最常见的信号就是在bash下启动一个程序按Ctrl+c 产生的硬件中断。如果CPU当前正在执行当前代码,则该进程的用户空间代码暂停,CPU就从用户态切换到内核态处理中断。

BTWCtrl-c产生的信号只能是前台进程发送,有些通过命令后+&放到后台运行,这样不必等待进程结束就可以接受新的命令,shell就可以在一个前台进程的同时跑多个后台进程,信号相对于进程的控制流程来说是异步的。

系统定义的信号列表

luyunchengdeMacBook-Pro:~ luyuncheng$ kill -l
1) SIGHUP    2) SIGINT   3) SIGQUIT  4) SIGILL
5) SIGTRAP   6) SIGABRT  7) SIGEMT   8) SIGFPE
9) SIGKILL  10) SIGBUS  11) SIGSEGV 12) SIGSYS
13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGURG
17) SIGSTOP 18) SIGTSTP 19) SIGCONT 20) SIGCHLD
21) SIGTTIN 22) SIGTTOU 23) SIGIO   24) SIGXCPU
25) SIGXFSZ 26) SIGVTALRM   27) SIGPROF 28) SIGWINCH
29) SIGINFO 30) SIGUSR1 31) SIGUSR2

这些编号和宏定义以及介绍在usr/include/signal.h可以找到。

./asm-generic/signal.h:10:#define SIGHUP         1
./x86_64-linux-gnu/bits/signum.h:33:#define SIGHUP      1   /* Hangup (POSIX).  */
./x86_64-linux-gnu/asm/signal.h:19:#define SIGHUP        1

在编号34以上为实时信号,34以下为非实时信号。

SignalValueActionComment
SIGHUP1TermHangup detected on controlling terminal or death of controlling process
SIGINT2TermInterrupt from keyboard
SIGQUIT3CoreQuit from keyboard
SIGILL4CoreIllegal Instruction
SIGABRT6CoreAbort signal from abort(3)
SIGFPE8CoreFloating point exception
SIGKILL9TermKill signal
SIGSEGV11CoreInvalid memory reference
SIGPIPE13TermBroken pipe: write to pipe with no read
SIGALRM14TermTimer signal from alarm(2)
SIGTERM15TermTermination signal
SIGUSR130,10,16TermUser-defined signal 1
SIGUSR231,12,17TermUser-defined signal 2
SIGCHLD20,17,18IgnChild stopped or terminated
SIGCONT19,18,25~~Continue if stopped
SIGSTOP17,19,23StopStop process
SIGTSTP18,20,24StopStop typed at tty
SIGTTIN21,21,26Stoptty input for background process
SIGTTOU22,22,27Stoptty output for background process

其中Action中,Term表示终止当前进程,Core表示终止当前进程并且CoreDump,Ign表示忽略信号,Stop表示停止当前进程,Cont表示继续执行先前停止的进程。

产生信号的条件有:

  1. bash按下时,终端会发送信号给前台,Ctrl-C产生SIGINT信号,Ctrl-\产生SIGQUIT信号,Ctrl-Z产生SIGTSTP信号,上述信号使得进程停止。
  2. 硬件异常信号,条件由硬件检测到并通知内核,然后内核向当前进程发送信号。例如执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
  3. 一个进程调用kill(2)函数可以发送信号给另一个进程。
  4. 可以用kill(1)命令发送信号给某个进程,kill(1)命令也是调用kill(2)函数实现的,如果不明确指定信号则发送SIGTERM信号,该信号的默认处理动作是终止进程。
  5. 当内核检测到某种软件条件发生时也可以通过信号通知进程,例如闹钟超时产生SIGALRM信号,向读端已关闭的管道写数据时产生SIGPIPE信号。

信号的产生

终端产生信号

前面说Term是使终止当前进程,Core表示终止当前进程并有CoreDump。CoreDump是指当一个进程终止时,就把进程的用户态中内存数据保存到磁盘里面,文件名字是core,这个就是CoreDump。这种方法主要是用于debug,比方说程序跑的时候有错误的时候,异常终止,那么事后就可以通过core文件找bug。一个进程允许产生的core文件大小由PCB中ResourceLimit这个变量决定。默认不让产生core文件,可以用ulimit 改变限制。

$ ulimit -c 1024 //改变Resource Limit 大小为1024K

然后跑程序的时候Ctrl+\ 就会产生core dumped

函数产生信号

#include<stdio.h>
//t1.c
int main()
{
    while(1){}
    return 0;
}

调用kill函数 &后台运行

root@ubuntu:~/Workspace/luyuncheng/ex_c++# ./t1 &
[1] 12137
root@ubuntu:~/Workspace/luyuncheng/ex_c++# kill -SIGSEGV 12137
root@ubuntu:~/Workspace/luyuncheng/ex_c++#  回车
[1]+  Segmentation fault      (core dumped) ./t1

&是后台运行,12137是进程的pid,之所以要回车才显示Segmentation fault,因为在12137进程终止掉之前已经回到了Shell提示符等待用户输入下一条命令,Shell不希望Segmentation fault信息和用户的输入交错在一起,所以等用户输入命令之后才显示。指定某种信号的kill命令可以有多种写法,上面的命令还可以写成kill -SEGV 12137kill -11 7940,11是信号SIGSEGV的编号。以往遇到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发SIGSEGV也能产生段错误。

kill函数就是給指定进程发送信号,raise就是給当前进程发送指定的信号(自己发自己)
函数原型: 成功返回0,错误返回-1

#include <signal.h>
int kill(pid_t pid, int signo);//給pid发送signo信号
int raise(int signo);//给自己发signo信号
void abort(void);//給自己发SIGABRT信号终止

软件产生的条件信号

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

alarm函数就是告诉内核seconds秒后給进程发SIGALRM信号,这个信号的默认处理动作时终止当前进程。这个函数有一个unsigned int的返回值,表示与设定的时间还余下的秒数。如果传入的值为0,表示取消当前设定的alarm。

信号的阻塞

我们称执行信号的处理动作称为传递(delivery),信号从产生到传递之间的状态,称为pending。进程可以选择阻塞(Block)某个信号。被阻塞的信号产生时将保持在pending的状态,直到阻塞解除后,执行delivery状态。note:阻塞和忽略是不同的,信号被阻塞不会传递,但是忽略是在delivery之后可选的一种处理动作。信号在内核中的表示可以看作是:

signal.internal
每个信号都有两个标志位分别表示阻塞和未决,还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,
1. SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
2. SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
3. SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。

    如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
    

信号集操作函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印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);

函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。注意,在使用sigset_t类型的变量之前,一定要调用sigemptysetsigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddsetsigdelset在该信号集中添加或删除某种有效信号。这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。

sigprocmask

调用函数sigprocmask可以读取或更改进程的信号屏蔽字。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
how参数的含义:

HOWComment
SIG_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号,相当于`mask=mask
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set
SIG_SETMASK设置当前信号屏蔽字为set所指向的值,相当于mask=set

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。

sigpending

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

sigpending读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

例子:

#include <signal.h>
#include <stdio.h>
#include <unistd.h>

void printsigset(const sigset_t *set)
{
    int i;
    for (i = 1; i < 32; i++)
        if (sigismember(set, i) == 1)
            putchar('1');
        else
            putchar('0');
    puts("");
}

int main(void)
{
    sigset_t s, p;
    sigemptyset(&s);
    sigaddset(&s, SIGINT);
    sigprocmask(SIG_BLOCK, &s, NULL);
    while (1) {
        sigpending(&p);
        printsigset(&p);
        sleep(1);
    }
    return 0;
}

程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会使SIGINT信号处于未决状态,按Ctrl-\仍然可以终止程序,因为SIGQUIT信号没有阻塞。

$ ./a.out 
0000000000000000000000000000000
0000000000000000000000000000000(这时按Ctrl-C)
0100000000000000000000000000000
0100000000000000000000000000000(这时按Ctrl-\)
Quit (core dumped)

捕捉信号

内核对于信号的捕捉

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
1. 用户程序注册了SIGQUIT信号的处理函数sighandler。
2. 当前正在执行main函数,这时发生中断或异常切换到内核态。
3. 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
4. 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
5. sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
6. 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
信号的捕捉: [ref:Understanding the Linux Kernel. 3. Daniel P. Bovet和Marco Cesati.]
signalcatch

sigaction

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:

struct sigaction {
   void      (*sa_handler)(int);   /* addr of signal handler, */
                                       /* or SIG_IGN, or SIG_DFL */
   sigset_t sa_mask;               /* additional signals to block */
   int      sa_flags;              /* signal options, Figure 10.16 */

   /* alternate handler */
   void     (*sa_sigaction)(int, siginfo_t *, void *);
};  

sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段,有兴趣的读者参考[Advanced Programming in the UNIX Environment. 2. W. Richard Stevens和Stephen A. Rago.]

pause

#include <unistd.h>
int pause(void);

pause函数使调用进程挂起直到有信号递达。如果信号的处理动作是终止进程,则进程终止,pause函数没有机会返回;如果信号的处理动作是忽略,则进程继续处于挂起状态,pause不返回;如果信号的处理动作是捕捉,则调用了信号处理函数之后pause返回-1,errno设置为EINTR,所以pause只有出错的返回值(想想以前还学过什么函数只有出错返回值?)。错误码EINTR表示“被信号中断”。

下面我们用alarm和pause实现sleep(3)函数,称为mysleep。
例子:mysleep

#include <unistd.h>
#include <signal.h>
#include <stdio.h>

void sig_alrm(int signo)
{
    /* nothing to do */
}

unsigned int mysleep(unsigned int nsecs)
{
    struct sigaction newact, oldact;
    unsigned int unslept;

    newact.sa_handler = sig_alrm;
    sigemptyset(&newact.sa_mask);
    newact.sa_flags = 0;
    sigaction(SIGALRM, &newact, &oldact);

    alarm(nsecs);
    pause();

    unslept = alarm(0);
    sigaction(SIGALRM, &oldact, NULL);

    return unslept;
}

int main(void)
{
    while(1){
        mysleep(2);
        printf("Two seconds passed\n");
    }
    return 0;
}

1. main函数调用mysleep函数,后者调用sigaction注册了SIGALRM信号的处理函数sig_alrm
2. 调用alarm(nsecs)设定闹钟。
3. 调用pause等待,内核切换到别的进程运行。
4. nsecs秒之后,闹钟超时,内核发SIGALRM给这个进程。
5. 从内核态返回这个进程的用户态之前处理未决信号,发现有SIGALRM信号,其处理函数是sig_alrm
6. 切换到用户态执行sig_alrm函数,进入sig_alrm函数时SIGALRM信号被自动屏蔽,从sig_alrm函数返回时SIGALRM信号自动解除屏蔽。然后自动执行系统调用sigreturn再次进入内核,再返回用户态继续执行进程的主控制流程(main函数调用的mysleep函数)。
7. pause函数返回-1,然后调用alarm(0)取消闹钟,调用sigaction恢复SIGALRM信号以前的处理动作。

可重入函数

当捕捉到信号时,不论进程的主控制流程当前执行到哪儿,都会先跳到信号处理函数中执行,从信号处理函数返回后再继续执行主控制流程。信号处理函数是一个单独的控制流程,因为它和主控制流程是异步的,二者不存在调用和被调用的关系,并且使用不同的堆栈空间。引入了信号处理函数使得一个进程具有多个控制流程,如果这些控制流程访问相同的全局资源(全局变量、硬件资源等),就有可能出现冲突,如下面的例子所示。

不可重入函数:
signal.reentrancy

main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?

如果一个函数符合以下条件之一则是不可重入的:

  1. 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  2. 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

sig_atomic_t类型与volatile限定符

在上面的例子中,main和sighandler都调用insert函数则有可能出现链表的错乱,其根本原因在于,对全局链表的插入操作要分两步完成,不是一个原子操作,假如这两步操作必定会一起做完,中间不可能被打断,就不会出现错乱了。下一节线程会讲到如何保证一个代码段以原子操作完成。

现在想一下,如果对全局数据的访问只有一行代码,是不是原子操作呢?比如,main和sighandler都对一个全局变量赋值,会不会出现错乱呢?比如下面的程序:

long long a;
int main(void)
{
    a=5;
    return 0;
}

带调试信息编译,然后带源代码反汇编:

$ gcc main.c -g
$ objdump -dS a.out

其中main函数的指令中有:

    a=5;
 8048352:       c7 05 50 95 04 08 05    movl   $0x5,0x8049550
 8048359:       00 00 00 
 804835c:       c7 05 54 95 04 08 00    movl   $0x0,0x8049554
 8048363:       00 00 00

虽然C代码只有一行,但是在32位机上对一个64位的long long变量赋值需要两条指令完成,因此不是原子操作。同样地,读取这个变量到寄存器需要两个32位寄存器才放得下,也需要两条指令,不是原子操作。请读者设想一种时序,main和sighandler都对这个变量a赋值,最后变量a的值发生错乱。

如果上述程序在64位机上编译执行,则有可能用一条指令完成赋值,因而是原子操作。如果a是32位的int变量,在32位机上赋值是原子操作,在16位机上就不是。如果在程序中需要使用一个变量,要保证对它的读写都是原子操作,应该采用什么类型呢?为了解决这些平台相关的问题,C标准定义了一个类型sig_atomic_t,在不同平台的C语言库中取不同的类型,例如在32位机上定义sig_atomic_t为int类型。

在使用sig_atomic_t类型的变量时,还需要注意另一个问题。看如下的例子:

#include <signal.h>

sig_atomic_t a=0;
int main(void)
{
    /* register a sighandler */
    while(!a); /* wait until a changes in sighandler */
    /* do something after signal arrives */
    return 0;
}

为了简洁,这里只写了一个代码框架来说明问题。在main函数中首先要注册某个信号的处理函数sighandler,然后在一个while死循环中等待信号发生,如果有信号递达则执行sighandler,在sighandler中将a改为1,这样再次回到main函数时就可以退出while循环,执行后续处理。用上面的方法编译和反汇编这个程序,在main函数的指令中有:

/* register a sighandler */
    while(!a); /* wait until a changes in sighandler */
 8048352:       a1 3c 95 04 08          mov    0x804953c,%eax
 8048357:       85 c0                   test   %eax,%eax
 8048359:       74 f7                   je     8048352 <main+0xe>

将全局变量a从内存读到eax寄存器,对eax和eax做AND运算,若结果为0则跳回循环开头,再次从内存读变量a的值,可见这三条指令等价于C代码的while(!a);循环。如果在编译时加了优化选项,例如:

$ gcc main.c -O1 -g
$ objdump -dS a.out

则main函数的指令中有:

8048352:       83 3d 3c 95 04 08 00    cmpl   $0x0,0x804953c
    /* register a sighandler */
    while(!a); /* wait until a changes in sighandler */
 8048359:       74 fe                   je     8048359 <main+0x15>

第一条指令将全局变量a的内存单元直接和0比较,如果相等,则第二条指令成了一个死循环,注意,这是一个真正的死循环:即使sighandler将a改为1,只要没有影响Zero标志位,回到main函数后仍然死在第二条指令上,因为不会再次从内存读取变量a的值。

是编译器优化得有错误吗?不是的。设想一下,如果程序只有单一的执行流程,只要当前执行流程没有改变a的值,a的值就没有理由会变,不需要反复从内存读取,因此上面的两条指令和while(!a);循环是等价的,并且优化之后省去了每次循环读内存的操作,效率非常高。所以不能说编译器做错了,只能说编译器无法识别程序中存在多个执行流程。之所以程序中存在多个执行流程,是因为调用了特定平台上的特定库函数,比如sigactionpthread_create,这些不是C语言本身的规范,不归编译器管,程序员应该自己处理这些问题。C语言提供了volatile限定符,如果将上述变量定义为volatile sig_atomic_t a=0;那么即使指定了优化选项,编译器也不会优化掉对变量a内存单元的读写。

对于程序中存在多个执行流程访问同一全局变量的情况,volatile限定符是必要的,此外,虽然程序只有单一的执行流程,但是变量属于以下情况之一的,也需要volatile限定:

  1. 变量的内存单元中的数据不需要写操作就可以自己发生变化,每次读上来的值都可能不一样
  2. 即使多次向变量的内存单元中写数据,只写不读,也并不是在做无用功,而是有特殊意义的

什么样的内存单元会具有这样的特性呢?肯定不是普通的内存,而是映射到内存地址空间的硬件寄存器,例如串口的接收寄存器属于上述第一种情况,而发送寄存器属于上述第二种情况。

sig_atomic_t类型的变量应该总是加上volatile限定符,因为要使用sig_atomic_t类型的理由也正是要加volatile限定符的理由。

竞态条件与sigsuspend函数

重新看到mysleep的代码:
设想这样的时序:

  1. 注册SIGALRM信号的处理函数。
  2. 调用alarm(nsecs)设定闹钟。
  3. 内核调度优先级更高的进程取代当前进程执行,并且优先级更高的进程有很多个,每个都要执行很长时间
  4. nsecs秒钟之后闹钟超时了,内核发送SIGALRM信号给这个进程,处于未决状态。
  5. 优先级更高的进程执行完了,内核要调度回这个进程执行。SIGALRM信号递达,执行处理函数sig_alrm之后再次进入内核。
  6. 返回这个进程的主控制流程,alarm(nsecs)返回,调用pause()挂起等待。
  7. 可是SIGALRM信号已经处理完了,还等待什么呢?

出现这个问题的根本原因是系统运行的时序(Timing)并不像我们写程序时所设想的那样。虽然alarm(nsecs)紧接着的下一行就是pause(),但是无法保证pause()一定会在调用alarm(nsecs)之后的nsecs秒之内被调用。由于异步事件在任何时候都有可能发生(这里的异步事件指出现更高优先级的进程),如果我们写程序时考虑不周密,就可能由于时序问题而导致错误,这叫做竞态条件(Race Condition)。

如何解决上述问题呢?读者可能会想到,在调用pause之前屏蔽SIGALRM信号使它不能提前递达就可以了。看看以下方法可行吗?

  1. 屏蔽SIGALRM信号;
  2. alarm(nsecs);
  3. 解除对SIGALRM信号的屏蔽;
  4. pause();

从解除信号屏蔽到调用pause之间存在间隙,SIGALRM仍有可能在这个间隙递达。要消除这个间隙,我们把解除屏蔽移到pause后面可以吗?

  1. 屏蔽SIGALRM信号;
  2. alarm(nsecs);
  3. pause();
  4. 解除对SIGALRM信号的屏蔽;

这样更不行了,还没有解除屏蔽就调用pause,pause根本不可能等到SIGALRM信号。要是“解除信号屏蔽”和“挂起等待信号”这两步能合并成一个原子操作就好了,这正是sigsuspend函数的功能。sigsuspend包含了pause的挂起等待功能,同时解决了竞态条件的问题,在对时序要求严格的场合下都应该调用sigsuspend而不是pause。

#include <signal.h>

int sigsuspend(const sigset_t *sigmask);

和pause一样,sigsuspend没有成功返回值,只有执行了一个信号处理函数之后sigsuspend才返回,返回值为-1,errno设置为EINTR。

调用sigsuspend时,进程的信号屏蔽字由sigmask参数指定,可以通过指定sigmask来临时解除对某个信号的屏蔽,然后挂起等待,当sigsuspend返回时,进程的信号屏蔽字恢复为原来的值,如果原来对该信号是屏蔽的,从sigsuspend返回后仍然是屏蔽的。

以下用sigsuspend重新实现mysleep函数:

unsigned int mysleep(unsigned int nsecs)
{
    struct sigaction    newact, oldact;
    sigset_t            newmask, oldmask, suspmask;
    unsigned int        unslept;

    /* set our handler, save previous information */
    newact.sa_handler = sig_alrm;
    sigemptyset(&newact.sa_mask);
    newact.sa_flags = 0;
    sigaction(SIGALRM, &newact, &oldact);

    /* block SIGALRM and save current signal mask */
    sigemptyset(&newmask);
    sigaddset(&newmask, SIGALRM);
    sigprocmask(SIG_BLOCK, &newmask, &oldmask);

    alarm(nsecs);

    suspmask = oldmask;
    sigdelset(&suspmask, SIGALRM);    /* make sure SIGALRM isn't blocked */
    sigsuspend(&suspmask);            /* wait for any signal to be caught */

    /* some signal has been caught,   SIGALRM is now blocked */

    unslept = alarm(0);
    sigaction(SIGALRM, &oldact, NULL);  /* reset previous action */

    /* reset signal mask, which unblocks SIGALRM */
    sigprocmask(SIG_SETMASK, &oldmask, NULL);
    return(unslept);
}

如果在调用mysleep函数时SIGALRM信号没有屏蔽:

  1. 调用sigprocmask(SIG_BLOCK, &newmask, &oldmask);时屏蔽SIGALRM。
  2. 调用sigsuspend(&suspmask);时解除对SIGALRM的屏蔽,然后挂起等待待。
  3. SIGALRM递达后suspend返回,自动恢复原来的屏蔽字,也就是再次屏蔽SIGALRM。
  4. 调用sigprocmask(SIG_SETMASK, &oldmask, NULL);时再次解除对SIGALRM的屏蔽。

关于SIGCHLD信号

进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。

事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。请编写程序验证这样做不会产生僵尸进程。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值