欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

进程信号

程序员文章站 2022-07-12 11:18:37
...

信号时一种从软件层面上对中断的模拟,很多重要的程序都需要处理信号,信号提供了一种处理异步事件的方法。比如,用户在终端按下 ctrl C 会终止一个进程,或者通过 kill 命令来给特定的进程发送信号。

信号基本概念

每个信号都有一个名字,这些名字以SIG 开头,在头文件 <signal.h> 中,这些信号名被定义为正整数常量(信号编号)。我们可以通过 kill -l 查看系统定义的信号列表。如下:

进程信号

如上:ctrl C 对应 SIGINT 信号,信号编号是2。

产生信号的条件:

  • 当用户按下某些终端按键时,会产生终端信号。如上面提到的 ctrl C
  • 硬件异常产生信号:如除 0 异常, 无效的地址访问(对NULL指针解引用、内存越界访问),这些信号通常有计算机硬件检测到,并通知操作系统内核,内核再给触发异常的进程发送合适的信号,如:对无效内存的解引用的进程将受到 SIGSEGV 信号;
  • 通过系统调用函数: int kill(pid_t id, int sig);,下面是一个例子:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>


int main()
{
    int id = fork();
    int count = 0;
    if(id < 0){
        perror("fork");
        return -1;
    }else if(id == 0){//child
        for(;;){
            printf("%d\n", count++);
            usleep(1000*200);
        }
    }else{//father
        sleep(3);
        kill(id, SIGKILL);
        wait(0);
    }
    return 0;
}

上面这段代码中,子进程做死循环打印动作,父进程睡眠 3 秒后给子进程发送信号,将其终止。

  • 命令行中通过 kill [opt] <pid> 命令给指定进程发送 指定命令;
  • 当检测到某些软件条件产生时,应将其通知有关进程并产生信号。比如:SIGALRM(进程设置的定时器超时),SIGPIPE(管道的读进程终止后,一个进程写管道)。

信号的处理方式:

  • 忽略此信号。有两种信号不能忽略,它们是 SIGKILLSIGSTOP ,原因是:它们像内核和超级用户提供了是进程终止的可靠方法。
  • 扑捉信号。通知内核在某种信号发生时,调用一个用户函数,在该函数中,执行用户希望对该信号处理的动作。如 一个子进程终止时,会想其父进程发送 SIGCHLD 信号,所以我们可以自定义信号的捕捉函数,在该函数中调用 waitpid 以获取子进程的 ID 和退出状态;
  • 执行默认动作,大多数系统默认动作是终止该进程。

信号的底层机制

我们来看看,当我们在键盘上敲下 ctrl C 时操作系统都发生了什么。
进程信号

可以看到,对某一个进程发送信号实际上是,将该进程 PCB 的某个字段设置一个值,那么这一点在 PCB 中更是怎样展现的呢?看下面这张图:
进程信号

执行信号的处理动作称为信号递达(Delivery),信号从产生到递达之间的状态,称为信号未决(Pending)。进程可以选择阻塞(Block)某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。注意,阻塞和忽略是不同,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

可以将信号在 PCB 中的标识看为上图中的三张表:

  • block 又称为信号屏蔽状态字,它是个 64bit 的位图,每个位代表一个信号,对应位为1,则表示信号被阻塞;
  • pending 又称为信号未决状态字, 它也是个 64bit 的位图,每个位代表一个信号,对应位为1代表未决,0代表信号可以递达了;
  • handler 表中记录了每种信号的处理方式,对于用户自定义的处理函数,该表对应位上为信号处理函数的地址。

注意:

  • 比如向进程发送进程 SIGINT 信号,内核首先判断信号屏蔽状态字是否阻塞,如果该信号被设为为了阻塞的,那么信号未决状态字(pending)相应位制成1;若该信号阻塞解除,信号未决状态字(pending)相应位制成0;表示信号此时可以抵达了,也就是可以接收该信号了;
  • 信号设计的机制是:用户可以读写信号屏蔽状态字 ,但只能读信号未决状态字。

信号的捕捉:
信号的处理动作为用户自定义函数,在信号递达是就调用该函数,这个动作称为信号的捕捉。用户自定义的信号处理函数在用户空间内,所以其相应的处理就要涉及到 “用户态——内核态” 之间的相互切换,过程比较复杂。比如用户注册了 SIGINT 信号的处理函数 myHandler(),那么当进程发生中断或者异常后,就切换到内核态,在处理完中断异常,在返回用户态之前,内核检查到有信号 SIGINT 递达,那么内核就返回至用户态执行 myHandler() 信号处理函数(并没有返回到中断是的上下文),main 函数和信号吹函数 myHandler() 不存在调用与被调用的关系,它们是两个独立的控制流。在执行完 myHandler() 后,通过特殊的系统调用 sigreturn 返回值内核态。再次检查是否有信号递达,如果没有,这次就返回至原来终端处上下文开始执行。这整个过程对应着下图:
进程信号

信号相关 API 函数

  • signal
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);

该函数看上去优点复杂,我们来分解分析它:

  • 函数名:signal
  • 参数:int signo, void (*func)(int),可以看到第二个参数是一个函数指针,它有三种取值:SIG_IGN>代表忽略该信号;SIGDFL>代表采取默认处理动作;用户自定义信号处理函数的地址>采取用户的处理动作。
  • 返回值: void (*)(int),函数的返回值是之前该信号的处理函数的指针。

下面通过例子来展示该函数的功能:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>

void myHandler(int sig)
{
    printf("sig: %d\n", sig);
}

int main()
{
    signal(SIGINT, myHandler);
    while(1){
    }

    return 0;
}

让该程序跑起来,并在终端按下 ctrl C

进程信号

此时我们发现 ctrl C 无法终止该进程,原因是:进程执行了我们自定义的对 SIGINT 信号的处理函数,而在我们自定义的函数中执行的代码是打印信号编号。

  • 信号集操作函数
#include <signal.h>  
int sigemptyset(sigset_t *set);//将信号集清空,共64bits  
int sigfillset(sigset_t *set);//将信号集置1  
int sigaddset(sigset_t *set, int signum);//将signum对应的位置为1  
int sigdelset(sigset_t *set, int signum);//将signum对应的位置为0  
int sigismember(const sigset_t *set, int signum);//判断signum是否在该信号集合中,如果集合中该位为1,则返回1,表示位于在集合中 
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);读取和更改屏蔽状态字
int sigpending(sigset_t *set);
  • sigemptyset() 函数初始化 set 所指向的信号集,将所有信号对应的 bit 位设置为 0,表示该信号集不包含任何有效信号;
  • 与第一个函数类似,将所有信号对应的 bit 位设置为 1;
  • sigaddsetsigdelset函数往信号集中添加或删除某种有效信号;
  • sigprocmask 用于读取和更改信号屏蔽字(即:阻塞信号集)。

    若果 oldset 非空,则读取当前信号屏蔽字并通过该参数传出,如果 set 非空,则更改进程的信号屏蔽字, how 参数指示如何更改。如果 oldsetset 都非空,则将原来的信号屏蔽字备份到 oldset 中,然后通过 how 指示的方式更改信号屏蔽字。 how 参数的可选值如下:


选项 描述
SIG_BLOCK set包含了我们希望添加到当前信号屏蔽字的信号,相当于 mask = mask | set
SIG_UNBLOCK set包含了我们希望从当前信号屏蔽字中解除的信号,相当于 mask = mask & ~set
SIG_SETMASK 设置当前信号屏蔽字为set所指向的值,相当于 mask = set
如果调用了sigprocmask解除了对当前若干个未决信号的屏蔽,则 sigprocmask返回前至少递达一个信号。
  • sigpending 用于读取当前进程的未决信号集,通过 set 参数传出。

上面的函数除了 sigismember 之外的,它们的执行成功返回 0,出错返回 -1,而 sigismember 判断一个信号集中是否包含某信号,包含返回 1,不包含返回 0, 出错返回 -1。

下面是一个例子:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>

void print_pending(sigset_t *p)
{
    int i = 1;
    for(; i <= 32; ++i){
        if(sigismember(p, i))
            putchar('1');
        else
            putchar('0');
    }
    putchar('\n');
}

int main()
{
    sigset_t s,p;
    sigemptyset(&s);
    sigemptyset(&p);
    sigaddset(&s, SIGINT); // ctrl C
    sigprocmask(SIG_BLOCK, &s, NULL); //阻塞 SIGINT(2号)信号

    while(1){
        sigpending(&p);//获取阻塞信号集
        print_pending(&p);//打印阻塞信号集
        sleep(1);
    }
    return 0;
}

当程序跑起来后,每隔一秒打印一次阻塞信号集,当我们在终端按下 ctrl C 时,可以看到打印出的阻塞信号集发生了变换,2 号信号被阻塞。

进程信号

sigaction

在前面我们学习了信号的捕捉,我们可以使用 signal 注册信号处理函数。但 signal 函数有很多缺陷,现今很多地方已经不用它,下面介绍另一种信号处理函数 sigaction

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

函数调用成功返回 0, 出错返回 -1, signo是要处理的信号的编号,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);
};
  • sa_handler:原型是一个参数为int,返回类型为void的函数指针。参数即为信号值,所以信号不能传递除信号值之外的任何信息。
  • sa_sigaction:原型是一个带三个参数,类型分别为int,struct siginfo ,void ,返回类型为void的函数指针。第一个参数为信号值;第二个参数是一个指向struct siginfo结构的指针,此结构中包含信号携带的数据值;第三个参数没有使用, 若果将sa_sigaction设置为SIGIGN则表示忽略此信号, 设置为 SIGDFL表示采用默认信号处理方式。
  • sa_mask:指定在信号处理程序执行过程中,哪些信号应当被阻塞。默认当前信号本身被阻塞。
    注意:sa_mask指定的信号阻塞的前提条件,是在由sigaction()安装信号的处理函数执行过程中由sa_mask指定的信号才被阻塞。
  • sa_flags:包含了许多标志位,比较重要的一个是SA_SIGINFO,当设定了该标志位时,表示信号附带的参数可以传递到信号处理函数中。即sa_sigaction指定信号处理函数,如果不设置SA_SIGINFO,信号处理函数同样不能得到信号传递过来的数据,在信号处理函数中对这些信息的访问都将导致段错误。

下面是一个该函数的使用实例:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>

void myHandler(int sig)
{
    printf("sig: %d\n", sig);
}

int main()
{
    struct sigaction newH, oldH;
    newH.sa_handler = myHandler;//注册信号处理函数
    sigemptyset(&newH.sa_mask);//清空信号屏蔽字
    newH.sa_flags = 0;
    sigaction(SIGINT, &newH, &oldH);
    while(1){
    }

    return 0;
}

运行结果和我们开始测试的另一个函数一样:
进程信号

那么signalsigaction 有什么区别呢?

  • signal在调用handler之前先把信号的handler指针恢复;sigaction调用之后不会恢复handler指针,直到再次调用sigaction修改handler指针。这样,signal就会丢失信号,而且不能处理重复的信号,而sigaction就可以。因为signal在得到信号和调用handler之间有个时间把handler恢复了,这样再次接收到此信号就会执行默认的handler。(虽然有些调用,在handler的以开头再次置handler,这样只能保证丢信号的概率降低,但是不能保证所有的信号都能正确处理)
  • signal在调用过程不支持信号block;sigaction调用后在handler调用之前会把屏蔽信号(屏蔽信号中自动默认包含传送的该信号)加入信号中,handler调用后会自动恢复信号到原先的值。signal处理过程中就不能提供阻塞某些信号的功能,sigaction就可以阻指定的信号和本身处理的信号,直到handler处理结束。这样就可以阻塞本身处理的信号,到handler结束就可以再次接受重复的信号。

自定义睡眠函数和竞态条件

pause函数

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

pause 使进程挂起直到有信号递达。如果信号的处理动作是终止进程,则进程终止,此时 pause 不返回,如果信号的处理动作是忽略,则进程继续处于挂起状态,pause不返回,如果信号的处理动作是捕捉,则在调用了信号处理函数后pause返回-1,errno设为EINTR。

alarm函数

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

alarm也称为闹钟函数,它可以在进程中设置一个定时器,当定时器指定的时间到时,它向进程发送SIGALRM 信号。可以设置忽略或者不捕获此信号,如果采用默认方式其动作是终止调用该alarm函数的进程。如果调用 alarm 前已经设置了闹钟,则返回剩余时间,否则返回 0;出错返回 -1。

mySleep函数
下面是我们实现的 mySleep 函数的思路:

  • 注册 SIGALRM 的处理函数,该函数什么也不干;
  • 调用 alarm 设定闹钟;
  • 调用 pause 等待;
  • alarm 设定的时间到,内核发送 SIGALRM 信号给该进程;
  • 进程从内核切换到用户态之前发现有未决信号,则调用用户注册的 SIGALRM 信号处理函数;
  • 进入 SIGALRM 处理函数前 SIGALRM 信号被屏蔽, 执行完该函数又解除对 SIGALRM 信号的屏蔽;
  • 通过系统调用 sigreturn 返回内核态,再次检查未决信号表,如果此时没有未决信号,则返回到用户态,接着主控制流程执行。
  • pause 函数返回-1,调用 alarm(0) 取消闹钟,恢复 SIGALRM 信号的处理动作。

下面是代码:

void myHandler(int sig){
    (void) sig;
}


unsigned int mySleep(unsigned int scon)
{
    struct sigaction s, oldHandler;
    unsigned int ret;
    //s.sa_handler = myHandler;
    sigemptyset(&s.sa_mask);
    s.sa_flags = 0;
    sigaction(SIGALRM, &s, &oldHandler);//注册信号处理函数

    alarm(scon);//调用alarm设定闹钟
    pause();//调用pause等待
    ret = alarm(0);//取消闹钟

    sigaction(SIGALRM, &oldHandler, NULL);//恢复之前的信号处理函数
    return ret;
}

我们在main 函数中调用一下它:

int main()
    while(1){
        mySleep(2);
        printf("hello world\n");
    }
    return 0;
}

运行结果每隔两秒打印一次。

几个问题:

  • 信号 SIGALRM 处理函数 myHandler 什么都没干,为什么还要注册它,不注册行不行?
    信号 SGALRM 的默认处理动作是终止调用它的进程,如果不注册它的处理函数的话,当我们设定的时间一到,则整个进程就会终止掉,这大大超出了我们的预期;

  • 为什么在 mySleep 函数返回前要恢复 SIGALRM 信号的处理动作?
    mySleep 函数应该应该仅仅完成睡眠对应时间的动作,而不应该带副作用的,而如果不恢复 SIGALRM 信号的处理动作,则等于在函数内部修改了 SIGALRM 信号的处理动作,更严重的后果是,在该程序的接下来的代码中,如果调用 alarm 函数,则不会得到期望的结果。

  • mySleep 函数的返回值代表什么?
    先看看 alarm 函数的返回值含义:如果调用 alarm 前已经设置了闹钟,则返回剩余时间,否则返回 0;出错返回 -1。 稍加分析,我们就可以知道,若进程挂起到参数所指定的时间则返回0,若有信号中断则返回剩余秒数。

重新审视一下我们的 mySleep ,如果在第一次调用 alarm 之后,cpu还没有来得及调用 pause, 该进程就被切换出去(),cpu 去执行优先更高的进程,而这些优先级更高的进程执行的时间可能超出我们设定的闹钟时间,所以当 alarm 超时后,内核发送 SIGALRM 信号给该进程,等到其它优先级更高的进程执行完后,内核在切换到本进程之前,检测到有未递达的未觉信号,于是去处理该信号,处理完成之后,才执行 pause 函数,可是此时,alarm 函数已经执行完,并且发送给本进程的 SIGALRM 信号已经被处理,pause 函数还在等什么呢?
由于异步时间任何时候都有可能发生,像上面这种由于程序时序产生的问题叫做竞态条件

如何解决上面的问题呢?一种好的方法是,将 “调用alarm设定闹钟” 和 “调用pause挂起等待” 这两步合并为一个原子操作,而 sigsuspend 函数正提供了这种功能。下面是它的原型:

#include <signal.h>
int sigsuspend(const sigset_t *sigmask);

该函数没有成功返回值,只有在执行了信号处理函数后,才返回 -1, 并将errno设置为:ENITR。调用 sigsuspend 函数后,该函数先将进程信号屏蔽字设为 sigmask 的所指定的值,然后挂起等待,当有信号递达,该函数返回时,再回复指定信号原来的屏蔽字。

下面是重新实现的 mySleep

unsigned int mysleep(unsigned int scon)
{
    int ret = 0;
    struct sigaction newH, oldH;
    sigset_t newMask, oldMask, susMask;

    newH.sa_handler = myHandler;
    sigemptyset(&newH.sa_mask);
    newH.sa_flags = 0;
    sigaction(SIGALRM, &newH, &oldH);

    sigemptyset(&newMask);
    sigaddset(&newMask, SIGALRM);
    sigprocmask(SIG_BLOCK, &newMask, &oldMask);// 屏蔽SIGALRM

    alarm(scon);//睡眠等待

    susMask = oldMask;
    sigdelset(&susMask, SIGALRM);

    //将信号屏蔽字设置为susMask并调用pause
    sigsuspend(&susMask);
    //sigsuspend返回时再回复屏蔽字,即又屏蔽SIGALRM

    ret = alarm(0);

    //用户主动还原最开始的信号屏蔽字
    sigaction(SIGALRM, &oldH, NULL);

    sigprocmask(SIG_SETMASK, &oldMask, NULL);

    return ret;
}

调用结果:和我们预期一样。

借助SIGCHLD信号处理僵尸进程

为了处理僵尸进程,我们需要再父进程中调用 waitwaitpid 来等待子进程,获取子进程的退出状态,这种方法要么父进程阻塞式等待,要么轮巡视等待,即没有效率上的优势,轮训的代码实现也较复杂。

实际上,在子进程退出时,会给其父进程发送一个 SIGCHLD 信号,我们可以自己注册该信号的处理函数,在信号处理函数中做一些善后的事情。代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>

void myHandler(int sig)
{
    (void) sig;
    pid_t id;
    while((id = waitpid(-1, NULL, WNOHANG)) > 0){
        printf("wait success: %d\n", id);
    }

    printf("child quit: %d\n", getpid());
}

int main()
{
    signal(SIGCHLD, myHandler);
    int id = fork();
    if(id < 0){
        perror("fork");
        return -1;
    }else if(id == 0){//child
        printf("child start...\n");
        sleep(3);
        printf("child finish...\n");
        exit(0);
    }

    while(1){
        printf("father runing!\n");
        sleep(1);
    }
    return 0;
}

运行结果:
进程信号

—— 完!


【作者:果冻:http://blog.csdn.net/jelly_9

相关标签: 信号 僵尸进程