CSAPP 第八章 编写信号处理程序的要点

作者 柚爸
  1. 安全的信号处理的原则
  2. 正确的信号处理
  3. 可移植的信号处理
  4. 信号处理中的同步问题
  5. 显式的等待信号
  6. 非本地跳转

安全的信号处理的原则

由于信号处理程序和主程序 共享同样的变量, 所以如何与主程序通信, 处理信号又不影响主程序运行, 就很重要了. 一般有如下原则:

  1. 处理程序尽可能简单. 简单并不是说程序要短小, 而是程序避免增加无谓的复杂度. 比如处理程序可以最终设置一个全局标志并且返回, 将处理这个全局标志的任务交给主程序. 主程序周期性的检查然后重置这个标记.
  2. 在处理程序中只调用异步信号安全的函数. Linux系统里有一些异步信号安全的函数, 这些函数有两个特点: 一是可重入(例如只访问局部变量), 二是不会被信号处理程序中断. 书的534页有所有的Linux 保证安全的系统函数.唯一输出安全的是系统调用函数 write, C语言中对其的包装函数 printf 和 sprintf 都是不安全的.
  3. 保存和恢复errno, 上边的很多安全函数都会在出错的时候设置errno, 如果不加设置, 会改变errno 的值, 由于errno是全局变量, 因此可能导致主程序出错. 解决办法是进入信号处理函数的时候保存 errno 的值, 结束的时候再设置成原来的值.
  4. 访问共享全局数据结构的时候, 阻塞全部信号. 否则在读写的时候如果被中断, 可能会造成一系列数据结构状态异常的结果.
  5. 用volatile声明全局变量. 如果像第一条说的设置一个全局标志, 但是编译器很可能认为这个变量其实没变化, 所以一直用寄存器中的数据, 不会更新. 使用volatile声明之后, 每次都会重读该变量的内存中值.
    对于这个全局标记本身, 也需要在访问的时候阻塞全部信号, 以保证一致性.
  6. 用 sig_atomic_t 声明第四条中提到的标志. 这个整型数据类型可以保证读和写是原子的. 结合第四条, 用一条语句来声明: volatile sig_automic_t flag;
    这个读和写指的是 直接设置 flag=常量, 如果是需要计算的语句比如 flag++ 或者 flag = flag + a, 都无法保证原子操作.

正确的信号处理

未处理的信号不会排队, 只有一个, 所以不能用信号来计数. 关键是要理解, 只要接收到一个信号, 就说明引起该信号的事件, 至少发生了一次, 因此应该针对这批事件进行处理, 否则便可能产生遗漏.

这个时候就可以来解决之前自己编写的bash程序的问题了: 即对于后台的进程没有进行任何跟踪(否则就变成前台进程了), 在其结束的时候也没有回收.

先来追踪一下 537 页上的程序:

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

#define MAXBUF 128

//信号处理函数
void handler1(int sig){
    //保存原来的全局变量 errno
    int olderrno = errno;
    //回收一个进程
    if ((waitpid(-1, NULL, 0)) < 0) {
        sio_error("waitpid error");
    }
    //调用异步安全函数
    Sio_puts("Handler reaped child\n");
    Sleep(1);
    //恢复 errno
    errno = olderrno;
}

int main(){
    int i, n;
    char buf[MAXBUF];
    //只要有一个子进程终止, 内核就会发送SIGCHLD信号给父进程, 所以给这个信号设置处理函数
    if (signal(SIGCHLD, handler1) == SIG_ERR) {
        unix_error("signal error");
    }
    //启动三个子进程, 每个显示自己的进程号
    for (i = 0; i < 3; i++) {
        if(Fork()==0){
            printf("Hello from child %d\n", (int) getpid());
            exit(0);
        }
    }

    //等待输入
    if ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0) {
        unix_error("read");
    }
    printf("Parent processing input\n");
    //无限循环
    while(1) {}
    exit(0);

}

运行这个程序, 可以发现输出如下:

Hello from child 1508
Handler reaped child
Hello from child 1509
Hello from child 1510
Handler reaped child

由于之后是无限循环, 这里可以按 Ctrl+Z 来挂起进程, 然后可以输入 ps 查看进程:

   PID TTY          TIME CMD
  1418 pts/0    00:00:00 bash
  1507 pts/0    00:00:00 singall
  1510 pts/0    00:00:00 singall <defunct>
  1511 pts/0    00:00:00 ps

可以看到有1508, 1509, 1510三个进程被创建(发送了Hello from), 然后显示回收了两个. 通过ps命令可以看到, 回收的是1508和1509, 1510进程被系统标记 defunct ,表示是一个结束的僵死进程.

这个问题就在于, 三个进程几乎是同一个时间结束, 在处理第一个结束信号的时候, 另外两个的信号也同时到达, 但是只有一个留在 pending 中, 所以等当前处理完之后, 再处理一次就结束了. 每一个信号处理的时候, 只回收一个进程, 结果3个进程只回收了2个.

要如何修改, 其实很简单, 之前说过, 收到信号说明此类型的事情发生了, 所以至少要处理一批事件. 所以将信号处理函数改成回收所有子进程的循环即可:

void handler2(int sig){
    //保存原来的全局变量 errno
    int olderrno = errno;

    //循环回收当前的所有子进程
        while((waitpid(-1, NULL, 0)) >0){
        Sio_puts("Handler reaped child\n");
    }

    //检测是不是有错误
    if (errno != ECHILD) {
        Sio_error("waitpid error");
    }

    Sleep(1);
    //恢复 errno
    errno = olderrno;
}

改用循环之后, 只要收到信号, 就去循环一次, 这里也可以使用 WNOHANG, 如果没有进程停止就休息一下. 这里还可以让子进程运行的时间更长一点, 然后只要结束一次, 就会去回收一次.

练习 8.8 程序的输出是什么

分析一下这个程序:

#include <signal.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
//用volatile设置的变量, 很可能就是全局标记
volatile long counter = 2;

//处理信号的函数
void handler1(int sig){
    //设置了两个信号集, 当前的是mask, 之前的是prev_mask
    sigset_t mask, prev_mask;

    //填充所有的信号到信号集中
    Sigfillset(&mask);
    //把信号集中的信号添加到blocked位向量中, 这么操作之后, 阻塞全部信号
    Sigprocmask(SIG_BLOCK, &mask, &prev_mask);
    //输出减少后的counter
    Sio_putl(--counter);
    //恢复原来的blocked位向量
    Sigprocmask(SIG_SETMASK, &prev_mask, NULL);
    //直接退出程序, _exit函数不关闭任何文件,不清除任何缓冲器、也不调用任何终止函数
    _exit(0);
}

int main(){
    //进程号变量
    pid_t pid;
    //信号集变量
    sigset_t mask, prev_mask;

    //打印counter计数器, 刷新输出, 此时应该是2
    printf("%ld", counter);
    fflush(stdout);
    //设置信号SIGUSR1(是用户定义的信号1)的处理函数是handler1
    signal(SIGUSR1, handler1);

    //分出来一个子进程, 子进程会无限循环. 父进程继续执行下边命令
    if ((pid = Fork()) == 0) {
        while(1) {
        }
    }

    //向刚刚的子进程发送SIGUSR1信号
    Kill(pid, SIGUSR1);

    //在发送之后, 子进程会调用信号处理程序, 信号处理程序会立刻屏蔽所有信号, 然后输出--counter = 1, 再恢复之后退出.

    //父进程等待所有子进程结束, 上边的子进程在处理完信号之后会退出,这里会等待
    Waitpid(-1, NULL, 0);

    //又重复设置阻塞全部信号
    Sigfillset(&mask);
    Sigprocmask(SIG_BLOCK, &mask, &prev_mask);
    //输出3, 注意这里的counter 是父进程自己的counter, 不是子进程的counter!
    printf("%ld", ++counter);
    Sigprocmask(SIG_SETMASK, &prev_mask, NULL);

    exit(0);
}
//最后的输出是213

通过分析之后, 可以发现输出的是213, 注意volatile是进程内的标志位, 父进程和子进程的volatile变量依然是独立的.

可移植的信号处理

信号处理在不同的系统上有不同的默认行为, 主要的区别在于:

  1. signal函数的语义不同, 有些系统在调用一次信号处理程序之后, 就会把信号处理行为恢复成默认, 因此必须再调用一次signal函数.
  2. 系统调用可以被中断, 前边一些安全的系统函数中的read ,write之类, 会阻塞进程一段时间. 在调用这些函数的时候, 如果发生信号, 这些函数会被中断, 在信号处理完毕的时候, 也不会再继续, 而是设置errno=EINTR,
    如果想要继续, 必须手工重启这些函数.

后来Posix标准定义了 sigaction 函数, 允许设置信号的时候指定信号处理语义:

#include <signal.h>

int sigaction(int signum, struct sigaction *act, struct sigaction *oldact);

这个函数需要定义一个行为的结构, 比较麻烦, 一般都是封装了一个函数使用, CSAPP中封好了这个函数. 这个函数的作用是:

  1. 阻塞与当前信号程序处理的相同类型的信号
  2. 被中断的系统调用重新启动
  3. 一旦设置就会一直存在, 直到被SIG_IGN或者SIG_DFL调用.

信号处理中的同步问题

信号处理程序由于也会和主程序是并发运行的, 只要并发程序, 都会涉及到同步的问题. 如果将程序看做一个一个指令的流, 这些流被操作系统其实是交错执行的, 而不是真的并发执行.

交错的执行造成的结果就是, 有些语句无论顺序和执行与否, 不会对最终结果产生影响, 而有些语句的先后执行顺序和执行与否, 直接会导致程序是否会产生正确的结果.

因此对于并发程序来说, 基本的工作是决定何时同步, 以保证并行的流可以不出错.

在一个程序中, 如果有了信号处理函数, 信号处理函数和主函数的流就会交错, 此时就可能发生错误:

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

void hanlder(int sig) {
    int olderrno = errno;
    sigset_t mask_all, prev_all;
    pid_t pid;

    //设置信号集为全部信号
    Sigfillset(&mask_all);
    //不断等待子进程的结束
    while ((pid = wait(-1, NULL, 0)) > 0) {
        //阻止全部信号
        Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
        //删除任务
        deletejob(pid);
        //恢复信号
        Sigprocmask(SIG_SETMASK, &prev_all, NULL);
    }
    //判断errno和恢复errno
    if (errno != ECHILD) {
        Sio_error("waitpid error");
    }
    errno = olderrno;
}

int main(int argc, char **argv){
    int pid;
    sigset_t mask_all, prev_all;

    //设置信号集为全部信号
    Sigfillset(&mask_all);
    //装载信号处理函数
    Signal(SIGCHLD, handler);
    //初始化任务列表
    initjobs();

    //不断开启子进程
    while(1){
        //子进程去执行程序
        if ((pid = Fork()) == 0) {
            Execve("/bin/date", argv, NULL);
        }
        //父进程将子进程的pid添加进addjob
        Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
        addjob(pid);
        Sigprocmask(SIG_SETMASK, &prev_all, NULL);
    }

    exit(0);
}

分析一段程序有没有并发错误, 需要将自己意图让程序执行的流程, 与程序并发时可能的各种情况进行对比. 特别是分布在并发程序中, 又必须按步骤执行的代码, 特别容易出现并发错误.

上边这段程序, 意图执行的顺序是 主进程fork->子进程执行date程序->将子进程加入到任务列表->子进程停止的时候发送信号->主进程信号处理程序删除任务.

问题在于, 信号处理程序和主进程是并发执行的, 是不是addjob一定会在deletejob之前发生呢? 也就是说,主进程执行到addjob()这一行是不是一定在信号发送之前?

画出拓扑图有助于分析:

分析图

可以看到, 拓扑图中间并行的部分无法保证执行顺序, 即addjob和deletejob可能会产生冲突.

如何解决这个问题, 观察拓扑图, 要保证addjob一定在deletejob之前执行, 只要把拓扑图改成addjob一定在分支之前就可以了, 那么也就是可以在Fork()之前阻塞所有子进程信号, 直到addjob之后再解除阻塞, 这样创建子进程之后, 即使有子进程结束了, 主进程也一定会将其添加到addjob中, 之后收到信号再逐个删除已经结束的子进程.

这里还有一个问题就是, 子进程在Fork之后会继承父进程的信号阻塞情况, 因此还必须解除掉才行.修改后的程序如下:

int main(int argc, char **argv){
    int pid;
    sigset_t mask_all, mask_one, prev_one;

    //设置父进程信号集为全部信号
    Sigfillset(&mask_all);

    //设置子进程的信号集为SIGCHLD
    Sigemptyset(&mask_one);
    Sigaddset(&mask_one, SIGCHLD);

    //装载信号处理函数
    Signal(SIGCHLD, handler);
    //初始化任务列表
    initjobs();

    //不断开启子进程
    while(1){
        //阻塞CHLD信号, 注意这里是阻塞父进程的信号
        Sigprocmask(SIG_BLOCK, &mask_one, &prev_one);

        //启动子进程执行程序, 子进程此时也处于阻塞CHLD信号的状态
        if ((pid = Fork()) == 0) {
            //解除子进程的CHLD信号阻塞
            Sigprocmask(SIG_SETMASK, &prev_one, NULL);
            //执行程序
            Execve("/bin/date", argv, NULL);
        }
        //父进程从只阻塞CHLD到阻塞全部信号
        Sigprocmask(SIG_BLOCK, &mask_all, NULL);
        //直到addjob执行之前, CHLD都被阻塞
        addjob(pid);
        //解除阻塞
        Sigprocmask(SIG_SETMASK, &prev_one, NULL);
    }

    exit(0);
}

此时的拓扑图如下:

同步图

可以看到, 解除阻塞信号之后, deletejob才会运行, 而在解除阻塞之前, addjob运行了. 这就让addjob永远运行在deletejob之前, 因为在进入子进程前就阻塞了CHLD信号, 子进程即使运行完毕, 主进程也会先调用addjob, 再处理信号.

显式的等待信号

比如Bash, 运行了一条命令, 就会等待作业终止, 被SIGCHLD信号处理程序回收. 来看一下如何写一个这种程序:

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

volatile sig_atomic_t pid;


void sigchld_handler(int s){
    int olderrno = errno;
    pid = waitpid(-1, NULL, 0);
    errno = olderrno;
}

void sigint_handler(int s){

}

int main(int argc, char **argv){

    sigset_t mask, prev;
    //设置SIGCHLD和SIGINT的信号处理函数
    Signal(SIGCHLD, sigchld_handler);
    Signal(SIGINT, sigint_handler);
    //将mask清空, 然后只加入SIGCHLD
    Sigemptyset(&mask);
    Sigaddset(&mask, SIGCHLD);

    while(1){
        //像上一节那样, 先阻塞信号, 再fork新进程
        Sigprocmask(SIG_BLOCK, &mask, &prev);

        if (Fork() == 0) {
            //这里是子进程的执行的代码, 可以执行各种任务, 这里简单退出了
            exit(0);
        }

        //下边都是父进程的代码
        pid = 0
        //恢复之前的信号状态, 这样就可以调用信号处理函数了.
        Sigprocmask(SIG_SETMASK, &prev, NULL);

        //反复等待pid不为0, 信号处理函数会让pid不为0, 这样程序就可以继续下去
        while (!pid) {
            ;
        }
        printf("child process has ended.\n");
    }

    exit(0);
}

这个程序的逻辑比较简单, 每一次开启一个新子进程的时候, 如果子进程还没有返回, 全局标志pid就是0, 此时主进程会一直循环等待, 只有子进程结束, 收到信号的时候, 信号处理程序会修改全局变量pid, 这样父进程就可以开始下一次执行任务.

在分支之前, 采用了上一节的技巧, 就是先阻塞CHLD信号, 重新设置好pid=0之后, 再取消阻塞CHLD信号.

这段代码的逻辑和同步方面是没有问题的. 程序的问题在于通过循环等待pid不为0的开销太大, 应该让主进程在等待子进程的过程中挂起就好了. 于是想到可不可以在while(!pid)中使用pause()函数.

答案是不能, 因为测试判断条件到进入pause()之间不是连续的, 如果先判断了pid不为0, 然后进入循环体, 但是还没有执行pause()之前, 就收到了信号, 之后pid就不为0了. 然后就没有任何信号了, pause()函数会一直停止下去.

换成不等待信号, 只是挂起一会的sleep可以, 但是时间无法有效的控制. 这里需要使用一个函数 sigsuspend, 定义在 signal.h 中:

#include <signal.h>

int sigsuspend(const sigset_t *mask);

这个函数的参数是一个信号集mask, 函数的作用是用这个mask替换当前的阻塞集合, 然后挂起进程, 直到进程收到信号. 如果这个信号有对应的处理程序, sigsuspend 会在信号处理完毕之后恢复原来的阻塞集合. 如果信号导致程序终止, sigsupend就不返回了, 因为程序已经终止了.

实际上这个函数相当于原子操作(一次性执行完毕)如下指令:

sigprocmask(SIG_SETMASK, &mask, &prev);
pause()
sigprocmask(SIG_SETMASK, &prev, NULL);

在第一行和第二行之间是连续操作的, 不会允许其他函数执行, 所以就没有了竞争. 利用这个函数可以修改原来的程序如下:

int main(int argc, char **argv){

    sigset_t mask, prev;
    //设置SIGCHLD和SIGINT的信号处理函数
    Signal(SIGCHLD, sigchld_handler);
    Signal(SIGINT, sigint_handler);
    //将mask清空, 然后只加入SIGCHLD
    Sigemptyset(&mask);
    Sigaddset(&mask, SIGCHLD);

    while(1){
        //像上一节那样, 先阻塞信号, 再fork新进程
        Sigprocmask(SIG_BLOCK, &mask, &prev);

        if (Fork() == 0) {
            exit(0);
        }

        //下边都是父进程的代码
        pid = 0;

        //反复等待pid不为0, 直接利用只包含SIGCHLD信号的mask信号集
        //调用 sigsuspend 已经表示只阻塞SIGCHLD信号了, 所以就不用先恢复全部信号状态, 等子进程结束之后再恢复就可以了.
        while (!pid) {
            sigsuspend(&mask);
        }

        //恢复之前的信号状态
        Sigprocmask(SIG_SETMASK, &prev, NULL);

        printf(".");
    }

    exit(0);
}

非本地跳转

语言中的try catch是如何实现的, 其实底层都是通过C语言的setjmp和longjmp函数来实现的, 这是纯粹的软件控制流, 成为非本地跳转.

如果一个函数套一个函数执行了很深了, 发现了一个错误, 得到一个错误码, 要一层一层解开调用栈, 把错误返回回去. 现在有这么一种机制, 就是在进入执行函数之前, 先通过 setjmp 保存了当前的环境. 在很深的地方发现错误的时候, 调用一个 longjmp 函数. longjmp函数会将最近调用的 setjmp 函数的环境恢复, 然后让setjmp函数返回一个值, 这个值就是错误码. 这样就实现了异常处理.

先来看setjmp函数:

#include <setjmp.h>

int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);

setjmp 被第一次调用时, 会在参数env中保存当前的调用环境, 包括PC, 栈, 寄存器值等, 然后返回0. setjmp的值不能够赋给变量, 但可以通过switch来操作.

之后是 longjmp 函数:

#include <setjmp.h>

void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval);

longjmp 函数的第二个参数, 不是给自己用的, 而是会让 setjmp 函数此时返回 retval 这个值. longjmp 函数执行的结果就好像是跳跃到 setjmp 最后一次执行的位置, 然后让 setjmp 函数重新有了一个返回值. 这个retval不能是0.

来看一个例子:

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

jum_buf buf;

int error1 = 0;
int error2 = 1;

void foo(void);

void bar(void);

int main(){
    //第一次调用的时候返回0
    switch (setjmp(buf)) {
        case 0:
            foo();
            break;
        case 1:
            printf("Detected an error1 condition in foo\n");
            break;
        case 2:
            printf("Detected an error2 condition in foo\n");
            break;
        default:
            printf("Unknown error condition in foo\n");
    }
    exit(0);
}

void foo(void){
    if (error1) {
        longjmp(buf, 1);
    }
    bar();
}

void bar(void){
    if (error2) {
        longjmp(buf, 2);
    }
}

这段代码里, 在第一次调用 setjmp 的时候, 会返回0. 因此语句执行foo(), foo()中会调用bar(). 如果foo()中发生错误, longjmp 会跳到setjmp的地方, 同时让其返回1. 而main函数就好像从调用setjmp的时点继续向下执行, 这一次测试的结果就变成了1, 于是就显示foo()中发生了错误.

对于bar()中出错也是类似的. 这里关键要理解, longjmp 跳回去的时候, 就好像程序从setjmp又开始执行一样, 唯一不同的是setjmp这次返回了不同的值.

longjmp的缺点是可能会产生内存泄露, 比如没有回收分配的内存.

对于信号处理, 则提供了setjmp和longjmp的信号版本, 这两个版本指的是可以被信号处理程序调用的版本.

#include <setjmp.h>
#include <signal.h>
#include <csapp.h>

sigjmp_buf buf;

void handler(int sig){
    //跳回到sigsetjmp的位置
    siglongjmp(buf, 1);
}

int main(){
    //在这里调用sigsetjmp来保存状态
    //第一次调用, 返回0, 会设置信号处理函数, 这样保证了先设置 setjmp, 之后才可能会有 longjmp, 否则会出问题
    if (!sigsetjmp(buf, 1)) {
        Signal(SIGINT, handler);
        Sio_puts("Starting ... \n");
    //longjmp之后, sigsetjmp不会返回0, 就执行这个分支
    } else {
        Sio_puts("Restarting ... \n");
    }

    while(1){
        Sleep(1);
        Sio_puts("Processing ... \n");
    }
    exit(0);
}

由于 sigsetjmp 和 siglongjmp 都不是安全的函数, 所以要在其内部只调用安全的函数. 像在 sigsetjmp 中调用了安全的Signal和Sio_puts, 在 siglongjmp 跳转的代码中执行了Sio_puts. sleep也是安全的.

异常控制流终于看完了, 虽然原理好理解, 但是感觉写起来由于和操作系统交互很多, 只能先了解一下理论了, 不了解Linux内核, 肯定也难以编写出来

linux下有一些工具可以用来监控和操作进程, 比如 STRACE, PS, TOP, PMAP, /proc 等.