CSAPP 第八章 信号

作者 柚爸
  1. 信号
  2. 发送信号
  3. 接收信号
  4. 阻塞和解除阻塞信号

信号

在进程这一节, 都在使用系统调用. 系统调用也是属于四种异常之一的陷阱. 四种异常也都是软硬件相结合的方式来进行工作的.

除了软硬件相结合的异常处理可以迅速改变状态, 操作系统还提供了更高层次的纯软件的异常, 叫做Linux信号.

一个信号就是一个短小的消息, 通知某个进程, 系统中发生了一个某种类型的事件, Linux有30种不同的信号. 每一个信号都对应于某种系统事件. 由于内核异常处理程序处理的异常对于用户程序来说是不可见的,
所以操作系统提供了一个软件机制, 通知用户进程发生了这些异常.

比如一个进程的运算试图除以0, 在执行到除以0的指令的时候, CPU会发现异常然后按照异常表跳转到操作系统的异常处理程序. 如果异常处理程序只是默默的工作, 那我们的程序看上去好像没有问题, 然而我们的程序会中止,
这是因为操作系统给用户程序发送了一个信号, 意思就是告诉用户程序出事了.

有几个术语如下:

  1. 发送信号. 内核通过更新目的进程的上下文中的某个状态来达成发送信号的行为. 两种原因可以导致发送信号: 1 内核检测到系统事件 2 进程调用kill函数. 一个进程可以发信号给自己, 也可以发给其他进程.
  2. 接收信号. 目的进程会被内核强迫对信号作出反应, 这也是通过更改上下文实现的, 作出反应就意味着接收信号. 进程可以选择忽略信号, 终止自己或者调用一个信号处理程序来做一些事情. 信号处理程序运行完之后,
    会将控制权再交给下一条指令.

一个发出但没有被接收的信号叫做待处理信号. 注意在任何时刻, 一种类型的信号至多只有一个, 如果再发送同类信号, 多出来的会被直接丢掉. 进程可以有选择的阻塞信号, 阻塞中的信号可以发送, 但是待处理信号不会被接收,
直到进程取消阻塞.

内核为每个进程维护着一个pending位向量, 其中存储所有待处理的信号. 还维护一个blocked位向量, 其中存放所有被阻塞的信号集合.

如果发送一个类型为k的信号, 内核就会设置pending中的第k位. 接收一个类型为k的信号, 内核就会清除pending的第k位.

发送信号

有很多机制和函数可以用来发送信号. 需要先知道进程组(process group)的概念. 因为很多时候, 信号是发送到进程组而不是单一的进程.

每个进程都属于一个进程组, 一个正整数id表示进程组号, getgprp函数可以用来获取当前的进程组号:

#include <unistd.h>
pid_t getpgrp(void);

还可以修改进程组号:

#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);

这个函数将进程号为pid的进程所属的进程组号改为pgid. 如果pid传入的是0, 就意味是当前进程. 如果pgid是0, 就意味着用pid当成进程组号.

所以, 一个进程号是20000的进程调用 setpgid(0, 0) ,结果是当前进程的pid=20000当成一个进程组号, 然后将当前的进程的进程组号改成20000.

知道了进程组, 就可以看如何发送信号了, 有如下几种方式:

  1. 用 /bin/kill 发送信号, 这条命令分为向单一进程发送信号和向进程组发送信号两种方式:
            linux> /bin/kill -9 15213
    

    这条命令表示向15213进程号的进程, 发送9号信号, 也就是SIGKILL信号.

            linux> /bin/kill -9 -20000
    

    这条命令表示向20000的进程组中所有的进程发送SIGKILL信号.

  2. 用键盘发送信号, 需要先知道作业(job)这个概念. 在任何时刻, 只有一个前台作业和0-多个后台作业. 在shell中启动程序的时候, 会为每个作业创建一个独立的进程组. 进程组ID通常取自进程组中的父进程id.
    在按下键盘Ctrl+C的时候, 内核会发送 SIGINT 信号到前台进程组的每一个进程, 如果进程没有对信号进程处理, 默认就是结束每个进程.
    输入Ctrl+Z会发送 SIGSTP 信号到前台进程中每个进程, 默认情况是挂起(停止)前台作业.
  3. 使用kill函数发送信号, 这个函数和使用kill命令很相似:

            #include <sys/types.h>
            #include <signal.h>
    
            int kill(pid_t pid, int sig);
    

    其中的第一个参数pid的正负决定了发送对象. 如果大于0就发送给pid的进程, 如果等于0就发送给自己所在进程组的所有进程, 如果小于0, 就按照绝对值发给pid的进程组内所有进程. sig则是signal.h中定义的宏

  4. alarm函数发送信号, alarm函数实际上是调用了操作系统对每个进程的计时器, 固定发送SIGALRM信号. 这个机制. 函数如下:
            #include <unistd.h>
            unsigned int alarm(unsigned int secs);
    

    如果把这个理解成一个闹钟的话, 第一次调用这个函数, 就会在指定的秒数之后, 调用程序收到SIGALRM信号. 如果secs传入0, 不会启动新的闹钟, 已经存在的闹钟还有效.
    如果secs不为0, 调用的时候已经有其他的闹钟存在, 就会覆盖原来没有到期的闹钟, 并且返回没有到期的闹钟在调用时候还剩余的秒数. 如果没有待处理的闹钟, 就返回0
    简单的说, 就是同时只能有一个闹钟存在, 调用的时候要么返回0, 要么返回上一个闹钟还剩余几秒.

发送信号的一大特点就是不能用信号来计数, 信号不会放入队列, 多个信号如果阻塞, 最后也只能收到一个. 所以信号一般用来通知状态的永久改变.

接收信号

接收信号的时点, 是在操作系统将p从内核模式切换到用户模式的时候, 也就是说, 一旦进行系统调用, 或者被系统调度器调度, 都会检查一次信号.

在这个时点, 系统会检查进程的未被阻塞的待处理信号的集合. 如果为空, 就继续执行进程, 如果不为空, 内核会选择集合中的某个信号(通常是最小的信号), 强制当前进程接收信号.

所谓接收信号, 就是进行某种行为, 这种行为进行完之后, 才会继续执行进程的下一条正常指令.

每个信号都有对应的默认行为, 如下:

  1. 进程终止
  2. 进程终止并转储内存
  3. 进程停止(挂起), 等待 SIGCONT 信号重新启动
  4. 进程忽略该信号

除了默认行为之外, 可以自定义信号处理函数, 也就是在接受到对应信号的时候所执行的函数. 可以使用 signal.h 中的 signal 函数, 来设置自定义信号处理函数.

# include <signal.h>
type def void (*sighandler_t)(int)

sighandler_t signal(int signum, sighandler_t handler);

signal函数接受的第一个参数是以宏形式定义的各种信号. 第二个参数是信号处理函数, 如果要将某个信号设置为默认行为, 传入宏 SIG_DFL 即可. 如果要忽略某个信号, 传入SIG_IGN即可.

如果不传入上述的两个宏, 而是传入一个参数为 int, 返回类型为 void 的函数指针, 这个函数就会成为自定义的信号处理函数.

注意, 并不是所有的信号都能设置自定义处理函数, 必须是可以被捕获和忽略的信号才可以, 系统中定义了 SIGKILL 和 SIGSTOP 这两个信号无法被捕获, 进程一定会使用默认行为而不是自定义行为.

在信号处理函数执行的过程中, 还能够再接收其他信号, 此时信号处理函数是一层一层返回的, 所以的信号都处理完了并且没有错误和终止, 才会继续执行进程的下一条普通指令.

练习 8.7 编写snooze程序

首先, 需要给main函数传递一个命令行参数, 所以要把用户的输入转换成int. 之后的流程是调用sleep函数, 在接受到Ctrl+C输入的时候, 这里懵逼了半天, 看了答案才明白, 不能够直接exit(0)来终止程序, 必须捕获信号之后直接返回就可以了.

因为信号会自动打断sleep函数, 执行了snooze就可以了.

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

int sleep_time;

unsigned int snooze(unsigned int secs) {
    unsigned int time = secs - sleep(secs);
    printf("Slept for %u of %u secs.\n", time, secs);
    return time;
}

void sighan(int sig){
    return;
}

int main(int argc, char *argv[], char *envp[]) {
    //获取参数时间并转换成int
    char *time;
    if (argv[1] == NULL) {
        time = "0";
    } else {
        time = argv[1];
    }
    sleep_time = atoi(time);

    //设置信号处理函数
    signal(SIGINT, sighan);
    //启动snooze函数, 如果未来被打断的时候, 信号处理函数不中止程序, sleep就可以返回了.
    snooze(sleep_time);
}

这道题目主要是不理解sleep的机制, 还以为信号处理函数需要去做一些什么事情. 其实不用, 只要收到信号, sleep就返回了, snooze函数就有值了. 所以处理SIGINT的方法就是什么都不做, 等待snooze函数返回即可.

阻塞和解除阻塞信号

阻塞是指的信号可以发送, 但不会被接收(也就是处理), 也就是信号会变成待处理信号. 待处理信号的只能保存一个.

阻塞信号有两种机制:

  1. 隐式的机制: 当一个信号处理程序正在处理一个信号的时候, 这个信号同类型的信号都被阻塞, 在信号处理完毕之前, 同类型的信号会变成pending.
  2. 显示的机制: 应用程序可以使用 sigprocmask 函数和其辅助函数来明确的阻塞和解除阻塞某个信号.

主要讨论显式机制, 这些函数有:

#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);

看一下这几个函数:

  1. sigprocmask函数的第一个参数是宏定义的, SIG_BLOCK表示把 set 中的信号 添加到 blocked 位向量中. SIG_UNBLOCK 表示把set中的信号从 blocked 位向量中删除. SIG_SETMASK 是将blocked 位向量设置成 set.

    如果oldset不是NULL话, 每次修改blocked位向量, 之前的位向量都会保存在oldset中.

  2. 下边的几个函数都是对set进行操作:

  3. sigemptyset: 初始化set为空
  4. sigfillset: 把所有信号都添加到set中
  5. sigaddset: 把指定的信号添加到set中, signum 依然是宏表示的信号
  6. sigdelset: 把指定的信号从set中删除, 如果成功返回0, 如果出错返回-1.
  7. sigismember: 检测某个信号是不是set的成员, 如果是就返回1, 不是就返回0.

set是一个sigset_t类型的指针. sigset_t这个类型被叫做信号集, 都定义在 signal.h中.

信号的部分设计到并发编程, 学了理论, 就要来看一下如何实际编写信号处理程序了.