Linux信号

概述

信号(signal)是一条消息,可以通知进程系统中发生了某种类型的事件。在Linux的shell中,我们可以通过“man 7 signal”获得Linux支持的所有信号类型。下图是Linux支持的30种信号:

915116-20170516204058697-1677295653.png

每种信号都对应于某种系统事件。比如:

  • 如果一个进程试图除以0,内核就会给该进程发送一个SIGFPE信号
  • 如果一个进程执行一条非法指令,内核就会给该进程发送一个SIGILL信号
  • 如果进程访问非法的存储器地址,内核就会给该进程发送一个SIGSEGV信号
  • 当一个子进程终止或被停止时,内核会发送一个SIGCHLD信号给它的父进程
  • 一个进程可以通过向另外一个进程发送一个SIGKILL信号,强制终止该进程

信号器

传送一个信号给目的进程由两个步骤组成:发送信号接收信号

一个只发出而没有被接收的信号叫做待处理信号(pending signal)。任何时刻,在一个进程中,一种类型至多只有一个待处理信号。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的k类型信号都不会排队,它们只是被简单地丢弃。

一个进程可以有选择地阻塞接收某种信号,当一种信号被进程阻塞时,该类型的信号仍然可以发送给该进程,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。

一个待处理信号最多只能被接收一次。内核为每个进程在待处理位向量(pending bit vector)中维护者待处理信号的集合,在阻塞位向量(blocked bit vector)中维护着被阻塞的信号集合。当发送一个类型为k的信号给进程时,内核会设置该进程的待处理位向量的第k位;当进程接收一个类型为k的信号时,内核会清除待处理位向量的第k位。

发送信号

内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程,发送信号有以下两个原因:

  • 内核检测到一个系统事件(比如除零错误或子进程终止)
  • 一个进程调用kill函数,显式地要求内核发送一个信号给目的进程(一个进程可以发送信号给它自己)

Linux系统提供了大量向进程发送信号的机制,所有这些机制都是基于进程组(process group)这个概念的。

进程组

每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。默认情况下,一个子进程和它的父进程同属于一个进程组。

可以通过getpgrp函数返回当前进程的进程组ID:

#include <unistd.h>

// return :调用进程的进程组ID
pid_t getpgrp(void);

可以通过setpgid函数改变进程自己或者其他进程的进程组:

#include <unistd.h>

// @pid  :进程ID
// @pgid :进程组ID
// return :若成功则为0,出错则为-1
int setpgid(pid_t pid, pid_t pgid);

setpgid函数将进程pid的进程组改为pgid:

  • 如果pid == 0,则设置当前进程的进程组为pgid
  • 如果pgid == 0,则设置进程pid的进程组为pid

例如,进程1000调用setpgid(0, 0),那么会创建一个新的新的进程组(进程组ID为1000),并将进程1000加入到这个新的进程组中。

从键盘发送信号

Linux shell使用作业(job)来表示为一条命令行求值而创建的进程。在任何时刻,至多有一个前台作业和0个或多个后台作业。shell为每个作业创建一个独立的进程组,典型的,进程组ID是取自作业中父进程中的一个PID。如下图所示:

915116-20170516204149182-645853813.png

图中展示一个前台作业和两个后台作业的shell,前台作业中的父进程PID为20,进程组ID也为20,父进程创建两个子进程,每个子进程也是进程组20的成员。

  • 当在终端输入ctrl-c时,会导致内核向每个前台进程组中的成员发送一个SIGINT信号
  • 当在终端输入ctrl-z时,会导致内核向每个前台进程组中的成员发送一个SIGTSPT信号

使用kill函数发送信号

进程可以通过调用kill函数发送信号给其他进程(也包括该进程自己):

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

// @pid : 进程ID
// @sig : 信号序号
// return : 若成功则为0,出错则为-1
int kill(pid_t pid, int sig);

这里需要解释一下pid:

  • 当pid > 0 时,kill发送信号sig给进程pid
  • 当pid == 0 时,kill发送信号sig给调用进程所在进程组中的每个进程,包括调用进程自己
  • 当pid < 0 时,kill发送信号sig给进程组abs(pid)中的每个进程

下面一个简单的示例,父进程使用kill函数发送SIGKILL信号给它的子进程:

// kill_test.c
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
    pid_t pid;
    
    /* 子进程挂起直到接收到SIGKILL信号,然后终止 */
    if((pid = fork()) == 0) {
        pause();
        printf("control should never reach here!\n");
        exit(0);
    }
    
    /* 父进程发送一个SIGKILL信号给子进程pid */
    printf("parent sends a SIGKILL signal to child(%d)\n", pid);
    kill(pid, SIGKILL);
    exit(0);
}

编译运行这个程序:

$ gcc -Wall kill_test.c -o kill_test

$ ./kill_test
parent sends a SIGKILL signal to child(8754)

使用alarm函数发送信号

进程可以通过调用alarm函数向它自己发送SIGALRM信号:

#include <unistd.h>

// @secs :设置闹钟的秒数
// return :前一次闹钟剩余的秒数,若以前没有设定闹钟,则为0
unsigned int alarm(unsigned int secs);

alarm函数安排内核在secs秒后,发送一个SIGALRM信号给调用程序:

  • 如果secs == 0,则不会调度安排新的闹钟
  • 任何情况下,对alarm的调用将取消任何待处理闹钟,并且返回任何待处理的闹钟在被发送前还剩下的秒数
  • 如果没有任何待处理的闹钟,就返回0

接收信号

当目的进程被内核强迫以某种凡是对信号的发送做出反应时,目的进程就接收了信号。对于接收到的信号,进程可以选择忽略终止或者通过执行一个信号处理程序(signal handler)的用户函数捕获该信号。

下图是一个进程捕获信号的主要流程,进程接收到信号后,进程被中断,并会触发控制转移到信号处理程序,当信号处理程序完成处理之后,它将控制权返回给被中断的进程。

915116-20170516204223010-201791024.png

当内核从一个异常处理程序返回,准备将控制返回个进程时,它会检查进程的未被阻塞的待处理信号集合(pendingg&~blocked)

  • 如果这个集合为空(通常情况是这样),那么内核将控制权传递给进程的下一条指令
  • 如果这个集合非空,那么内核选择集合中的某个信号k(通常是最小的k),并且强制进程接收信号k。接收到信号会触发进程的某种默认行为,每个信号都有一个默认行为,是以下四种之一:
  • 进程终止
  • 进程终止并转储存储器(core dump)
  • 进程停止直到被SIGCONT信号重启
  • 进程忽略该信号

Linux信号对应的默认行为可以参照上面的Linux信号图。值得注意的是:进程可以通过调用signal函数修改和信号相关联的默认行为。唯一的例外是SIGSTOP和SIGKILL,我们无法修改它们的默认行为,也无法捕获到这两个信号。

  • SIGSTOP信号的默认行为是停止进程,直到收到一个SIGCONT
  • SIGKILL信号的默认行为是终止进程
#include <signal.h>
typedef void (*sighandler_t)(int);

// @signum  : 信号的序号
// @handler : 与信号相关联的行为
// return   : 如果成功则返回指向前次处理程序的指针;如果出错则返回SIG_ERR,但不设置errno
sighandler_t signal(int signum, sighandler_t handler);

signal函数可以通过下面三种方式之一改变和信号signum关联的行为:

  • 如果handler是SIG_IGN,那么忽略类型为signum的信号
  • 如果handler是SIG_DEF,那么类型为signum的信号行为恢复为默认行为
  • 如果handler是用户定义的函数的地址(即信号处理程序的地址),只要进程接收到一个类型为signum的信号,就会调用这个函数。

通过将信号处理程序的地址传递到signal函数从而改变默认行为的过程,称为设置信号处理程序(installing the handler)。调用信号处理程序叫做捕获信号,执行信号处理程序叫做处理信号

下面请看一个示例:

// sigint_test.c

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

void handler(int sig)
{
    printf("Caught SIGINT\n");
    exit(0);
}

int main()
{
    /* 设置捕获SIGINT的信号处理程序 */
    if(signal(SIGINT, handler) == SIG_ERR)
    {
        fprintf(stderr, "signal error : %s", strerror(errno));
        exit(0);
    }
    
    /* 挂起,直到收到某个信号 */
    pause();
    
    exit(0);
}

SIGINT信号的默认行为是立即终止该进程。在这个例子中,我们将默认行为修改为捕获信号,输出一条信息,然后终止该进程。我们在shell中运行该程序,输入ctrl-c,在信号处理函数中输出一条信息,然后退出:

$ ./sigint_test
# 按下ctrl-c
Caught SIGINT
$

值得注意的是,信号处理程序可以被其他类型的信号处理程序中断。如下图所示,主程序捕获到信号s,s中断主程序,并将控制转移到信号处理程序S。当S在运行时,程序捕获信号t(t的类型与s不同),这时候t会中断信号处理程序S的执行,并将控制转移到信号t的处理程序T中。当T返回时,S从它被中断的地方继续执行。最后,当S返回后,控制传送回主程序,主程序从它被中断的地方继续执行。

915116-20170516204317275-1863927022.png

信号处理问题

我们在上面举了一个简单的例子,并且说明了信号处理程序可以被其他类型的信号中断,这只是信号处理的一个常见的问题,实际上,当一个程序需要捕获多个信号的时候,会遇到很多问题:

  • 待处理信号被阻塞:信号处理程序通常会阻塞与当前信号处理程序正在处理类型相同的待处理信号。比如,一个进程捕获到SIGCHLD信号,并且当前它正在运行SIGCHLD的处理程序。如果这时候有另外一个SIGCHLD信号传递到这个进程,那么SGICHLD信号将会变成待处理,但是不会被接收,直到信号处理程序返回。
  • 待处理信号不会排队等待:任意类型至多只有一个待处理信号。当存在一个待处理信号的时候,仅仅表明至少已经有一个信号到达。比如,有两个SIGCHLD信号传送到当前进程,而当前进程正在运行SIGCHLD处理程序,这是SIGCHLD信号是被阻塞的,那么两个SIGCHLD信号中的第二个将被丢弃,因为信号不会排队等待。
  • 系统调用可以被信号中断:有一些系统调用被称为慢速系统调用,比如read、accept,调用它们可能会组成进程一段较长的时间。在一些*nix系统中,当处理程序捕获到一个信号时,被中断的慢速系统调用在信号处理程序返回后不再继续,而是立即返回给用户一个错误条件,并且将errno设置为EINTR。

一个处理SIGCHLD的例子

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

void sigchld_handler(int sig);

int main()
{
    int i;
    
    /* 设置捕获SIGCHLD的信号处理程序 */
    if(signal(SIGCHLD, sigchld_handler) == SIG_ERR) {
        fprintf(stderr, "signal error : %s", strerror(errno));
        exit(0);
    }
    
    /* 创建3个子进程 */
    for(i = 0; i < 3; ++i) {
        if(fork() == 0) {
            printf("child : %d\n", (int)getpid());
            sleep(1);
            exit(0);
        }
    }
    
    /* 父进程进入无限循环 */
    while(1)
        ;
    exit(0);
}

SIGCHLD的信号处理函数,每次回收一个已终止子进程:

void sigchld_handler(int sig)
{
    pid_t pid;
    
    /* 每次回收一个终止子进程 */
    if((pid = waitpid(-1, NULL, 0)) < 0) {  
        fprintf(stderr, "signal error : %s", strerror(errno));
        exit(0);
    }
    printf("Handler reaped child %d\n", (int)pid);
    sleep(2);
    return ;
}

运行上面的程序,父进程创建了3个子进程,等待十几秒后,发现只有两个信号被接收,调用两次信号处理函数,因此父进程只回收两个子进程:

$ ./ sigchild_test01
child : 8149
child : 8148
child : 8147
Handler reaped child 8148
Handler reaped child 8147
^Z
[1]+  Stopped                 ./signal_test1

这时候,我们挂起进程,通过ps命令,可发现,子进程8149没有被回收,成为一个僵死进程(在ps命令中由"defunct"字符串标记出)

$ ps
   PID TTY          TIME CMD
  7697 pts/1    00:00:00 bash
  8146 pts/1    00:00:03 signal_test1
  8149 pts/1    00:00:00 signal_test1 <defunct>
  8188 pts/1    00:00:00 ps

发生这种情况的原因是:父进程接捕获到第一个SIGCHLD信号,在执行SIGCHLD信号处理程序时,后面SIGCHLD信号被阻塞,并且后面两个SIGCHLD信号的第二个被抛弃,导致SIGCHLD只被接收两次,而我们的信号处理程序中每次只回收一个已终止子进程,因此最后有一个终止子进程没有被回收,成为僵死进程!这个程序出现上面提及的两种情况——待处理信号被阻塞待处理信号不会排队等待

解决这个问题,可以修改sigchld_handler函数,每当接收SIGCHLD信号时,尽可能多地回收终止子进程

void sigchld_handler(int sig)
{
    pid_t pid;
    
    /* 每次尽可能多地回收已终止子进程 */
    while((pid = waitpid(-1, NULL, 0)) > 0) 
        printf("Handler reaped child %d\n", (int)pid);
    /* 当没有已终止子进程时, */
    if(errno != ECHILD) {
        fprintf(stderr, "waitpid error : %s", strerror(errno));
        exit(0);
    }
    
    sleep(2);
    return ;
}

一个慢速系统调用被信号中断的例子

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

void sigchld_handler(int sig)
{
    pid_t pid;
    
    /* 每次尽可能多地回收已终止子进程 */
    while((pid = waitpid(-1, NULL, 0)) > 0) 
        printf("Handler reaped child %d\n", (int)pid);
    /* 当没有已终止子进程时, */
    if(errno != ECHILD) {
        fprintf(stderr, "waitpid error : %s", strerror(errno));
        exit(0);
    }
    
    return ;
}

int main()
{
    int n;
    char buf[1024];
    
    /* 设置捕获SIGCHLD的信号处理程序 */
    if(signal(SIGCHLD, sigchld_handler) == SIG_ERR) {
        fprintf(stderr, "signal error : %s", strerror(errno));
        exit(0);
    }

    /* 创建一个子进程 */
    if(fork() == 0) {
        printf("Child process\n");
        sleep(10);
        exit(0);
    }
    
    /* read可能会被SIGCHLD中断 */
    if((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0) {
        fprintf(stderr, "read error : %s", strerror(errno));
        exit(0);
    }
    
    /* 输出读取到的字符串 */
    buf[n] = '\0';
    printf("buf : %s", buf);
    exit(0);
}

在这个例子中,我们在主程序中创建一个子进程,子进程挂起10秒钟,在这期间,通过read从标准输入中读入数据到缓冲区中。read是个慢速系统调用,在read阻塞期间可能会被SIGCHLD信号中断:

  • 在Linux系统上,被中断的系统调用会自动重启,因此我们的程序可以正确执行
  • 在Solaris系统上,慢速系统调用被中断后不会重启,而是返回-1,并设置errno为EINTR,这种情况下,我们的程序会输出read error : Interrupted system call。

为了编写可移植的信号处理代码,我们必须考虑系统调用过早返回的可能,然后当它发生时,手动重启它们。对于上面的例子,可以如下修改read调用语句:

    /* 手动重启被中断的read调用 */
    while((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0) 
        if(errno != EINTR) {
            fprintf(stderr, "read error : %s", strerror(errno));
            exit(0);
        }

可移植的信号处理

不同系统之间,信号处理可能存在差异(比如,一个被中断的慢速系统调用是重启还是永久放弃)。为了解决这个问题,Posix标准定义了sigaction函数,它允许与Posix兼容的系统(比如Linux和Solaris)上的用户,明确指定信号处理语义。

#include <signal.h>

// @signum  : 信号序号
// @act     : 新的信号处理行为
// @oldact  : 旧的信号处理行为
// return : 成功返回0,失败返回-1
int sigaction(int signum, struct sigaction *act, struct sigaction *oldact);

但是sigaction的应用并不广泛,因为它要求用户设置多个结构条目。可以使用一个包装函数——Signal,来调用sigaction:

typedef void(*handler_t)(int signum);

handler_t *Signal(int signum, handler_t *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) {
        fprintf(stderr, "Signal error : %s", strerror(errno));
        exit(0);
    }
    return (old_action.sa_handler);
}

Signal的信号处理语义如下:

  • 只有这个处理程序当前正在处理的那种类型的信号被阻塞
  • 信号不会排队
  • 只要有可能,被中断的系统调用会自动重启
  • 一旦设置了信号处理函数,该信号处理函数会一直保持,知道Signal带着handler参数为SIGIGN或SIG_DFL被调用。

显式地阻塞和取消阻塞信号

我们可以通过sigprocmask函数显式阻塞和取消阻塞所指定的信号:

#include <signal.h>

// 改变当前已阻塞信号的集合
// return : 如果成功则为0,失败为-1
int sigprocmask(int how, sigset_t *set, sigset_t *oldset);

// 初始化set为空集
int sigemptymask(sigset_t *set);

// 将每个信号添加到set中
int sigfillset(sigset_t *set);

// 将指定信号添加到set中
int sigaddset(sigset_t *set, int signum);

// 将指定信号从set中删除
int sigdelset(sigset_t *set, int signum);

// 判断signum是否为set的成员
// return : 若signum是set的成员,则返回1;如果不是,则返回0;否则出错,返回-1
int sigsmember(const sigset_t *set, int signum);

sigprocmask函数改变当前已阻塞的信号的集合,具体行为依赖how的值:

  • SIG_BLOCK :添加set中的信号到blocked位向量中(blocked = blocked | set)
  • SIG_UNBLOCK : 从blocked位向量删除set中的信号(blocked = blocked & ~set)
  • SIG_SETMASK : blocked = set
    如果oldset非NULL,blocked以前的值会保存在oldset中

参考资料

  • Randal E. Bryant, David R. O’Hallaron, 布赖恩特,等. 深入理解计算机系统[M]. 机械工业出版社, 2011.
  • W. Richard Stevens, Stephen A. Rago, 史蒂文斯, 等. UNIX 环境高级编程[M]. 人民邮电出版社, 2014.

转载于:https://www.cnblogs.com/west000/p/6863567.html

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值