# 8.5.5 编写信号处理程序

信号处理是 Linux 系统编程最棘手的一个问题。处理程序有几个属性使得它们很难推理分析:1)处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰;2)如何以及何时接收信号的规则常常有违人的直觉;3)不同的系统有不同的信号处理语义。

在本节中,我们将讲述这些问题,介绍编写安全、正确和可移植的信号处理程序的一些基本规则。

# 1. 安全的信号处理

信号处理程序很麻烦是因为它们和主程序以及其他信号处理程序并发地运行,正如我们在图 8-31 中看到的那样。如果处理程序和主程序并发地访问同样的全局数据结构,那么结果可能就不可预知,而且经常是致命的。

我们会在第 12 章详细讲述并发编程。这里我们的目标是给你一些保守的编写处理程序的原则,使得这些处理程序能安全地并发运行。如果你忽视这些原则,就可能有引入细微的并发错误的风险。如果有这些错误,程序可能在绝大部分时候都能正确工作。然而当它出错的时候,就会错得不可预测和不可重复,这样是很难调试的。一定要防患于未然!

  • G0. 处理程序要尽可能简单。 避免麻烦的最好方法是保持处理程序尽可能小和简单。例如,处理程序可能只是简单地设置全局标志并立即返回;所有与接收信号相关的处理都由主程序执行,它周期性地检查(并重置)这个标志。
  • G1. 在处理程序中只调用异步信号安全的函数。 所谓异步信号安全的函数(或简称安全的函数)能够被信号处理程序安全地调用,原因有二:要么它是可重入的(例如只访问局部变量,见 12.7.2 节),要么它不能被信号处理程序中断。图 8-33 列出了 Linux 保证安全的系统级函数。注意,许多常见的函数(例如 printfsprintfmallocexit)都不在此列。
_Exit fexecve poll sigqueue
_exit fork posix_trace_event sigset
abort fstat pselect sigsuspend
accept fstatat raise sleep
access fsync read sockatmark
aio_error ftruncate readlink socket
aio_return futimens readlinkat socketpair
aio_suspend getegid recv stat
alarm geteuid recvfrom symlink
bind getgid recvmsg symlinkat
cfgetispeed getgroups rename tcdrain
cfgetospeed getpeername renameat tcflow
cfsetispeed getpgrp rmdir tcflush
cfsetospeed getpid select tcgetattr
chdir getppid sem_post tcgetpgrp
chmod getsockname send tcsendbreak
chown getsockopt sendmsg tcsetattr
clock_gettime getuid sendto tcsetpgrp
close kill setgid time
connect link setpgid timer_getoverrun
creat linkat setsid timer_gettime
dup listen setsockopt timer_settime
dup2 lseek setuid times
execl lstat shutdown umask
execle mkdir sigaction uname
execv mkdirat sigaddset unlink
execve mkfifo sigdelset unlinkat
faccessat mkfifoat sigemptyset utime
fchmod mknod sigfillset utimensat
fchmodat mknodat sigismember utimes
fchown open signal wait
fchownat openat sigpause waitpid
fcntl pause sigpending write
fdatasync pipe sigprocmask

图 8-33 异步信号安全的函数(来源:man 7 signal。数据来自 Linux Foundation)

信号处理程序中产生输出唯一安全的方法是使用 write 函数(见 10.1 节)。特别地,调用 printfsprintf 是不安全的。为了绕开这个不幸的限制,我们开发一些安全的函数,称为 SIO(安全的 I/O)包,可以用来在信号处理程序中打印简单的消息。

#include "csapp.h"

ssize_t sio_putl(long v);
ssize_t sio_puts(char s[]);

返回: 如果成功则为传送的字节数,如果出错,则为 -1。

void sio_error(char s[]);

返回: 空。

sio_putlsio_puts 函数分别向标准输出传送一个 long 类型数和一个字符串。sio_error 函数打印一条错误消息并终止。

图 8-34 给出的是 SIO 包的实现,它使用了 csapp.c 中两个私有的可重入函数。第 3 行的 sio_strlen 函数返回字符串 s 的长度。第 10 行的 sio_ltoa 函数基于来自 [61] 的 itoa 函数,把 v 转换成它的基 b 字符串表示,保存在 s 中。第 17 行的 _exit 函数是 exit 的一个异步信号安全的变种。

code/src/csapp.c

ssize_t sio_puts(char s[]) /* Put string */
{
    return write(STDOUT_FILENO, s, sio_strlen(s));
}

ssize_t sio_putl(long v) /* Put long */
{
    char s[128];

    sio_ltoa(v, s, 10); /* Based on K&R itoa() */
    return sio_puts(s);
}

void sio_error(char s[]) /* Put error message and exit */
{
    sio_puts(s);
    _exit(1);
}

图 8-34 信号处理程序的 SIO(安全 I/O)包

图 8-35 给出了图 8-30 中 SIGINT 处理程序的一个安全的版本。

code/ecf/sigintsafe.c

#include "csapp.h"

void sigint_handler(int sig) /* Safe SIGINT handler */
{
    Sio_puts("Caught SIGINT!\n"); /* Safe output */
    _exit(0);                     /* Safe exit */
}

图 8-35 图 8-30 的 SIGINT 处理程序的一个安全版本

  • G2. 保存和恢复 errno 许多 Linux 异步信号安全的函数都会在出错返回时设置 errno。在处理程序中调用这样的函数可能会干扰主程序中其他依赖于 errno 的部分。解决方法是在进入处理程序时把 errno 保存在一个局部变量中,在处理程序返回前恢复它。注意,只有在处理程序要返回时才有此必要。如果处理程序调用 _exit 终止该进程,那么就不需要这样做了。

  • G3. 阻塞所有的信号,保护对共享全局数据结构的访问。 如果处理程序和主程序或其他处理程序共享一个全局数据结构,那么在访问(读或者写)该数据结构时,你的处理程序和主程序应该暂时阻塞所有的信号。这条规则的原因是从主程序访问一个数据结构 d 通常需要一系列的指令,如果指令序列被访问 d 的处理程序中断,那么处理程序可能会发现 d 的状态不一致,得到不可预知的结果。在访问 d 时暂时阻塞信号保证了处理程序不会中断该指令序列。

  • G4. 用 volatile 声明全局变量。 考虑一个处理程序和一个 main 函数,它们共享一个全局变量 g。处理程序更新 gmain 周期性地读 g。对于一个优化编译器而言,maing 的值看上去从来没有变化过,因此使用缓存在寄存器中 g 的副本来满足对 g 的每次引用是很安全的。如果这样,main 函数可能永远都无法看到处理程序更新过的值。

    可以用 volatile 类型限定符来定义一个变量,告诉编译器不要缓存这个变量。例如:

    volatile int g;
    

    volatile 限定符强迫编译器每次在代码中引用 g 时,都要从内存中读取 g 的值。一般来说,和其他所有共享数据结构一样,应该暂时阻塞信号,保护每次对全局变量的访问。

  • G5. 用 sig_atomic_t 声明标志。 在常见的处理程序设计中,处理程序会写全局标志来记录收到了信号。主程序周期性地读这个标志,响应信号,再清除该标志。对于通过这种方式来共享的标志,C 提供一种整型数据类型 sig_atomic_t,对它的读和写保证会是原子的(不可中断的),因为可以用一条指令来实现它们:

    volatile sig_atomic_t flag;
    

    因为它们是不可中断的,所以可以安全地读和写 sig_atomic_t 变量,而不需要暂时阻塞信号。注意,这里对原子性的保证只适用于单个的读和写,不适用于像 flag++flag = flag + 10 这样的更新,它们可能需要多条指令。

要记住我们这里讲述的规则是保守的,也就是说它们不总是严格必需的。例如,如果你知道处理程序绝对不会修改 errno,那么就不需要保存和恢复 errno。或者如果你可以证明 printf 的实例都不会被处理程序中断,那么在处理程序中调用 printf 就是安全的。对共享全局数据结构的访问也是同样。不过,一般来说这种断言很难证明。所以我们建议你采用保守的方法,遵循这些规则,使得处理程序尽可能简单,调用安全函数,保存和恢复 errno,保护对共享数据结构的访问,并使用 volatilesig_atomic_t

# 2. 正确的信号处理

信号的一个与直觉不符的方面是未处理的信号是不排队的。因为 pending 位向量中每种类型的信号只对应有一位,所以每种类型最多只能有一个未处理的信号。因此,如果两个类型 k 的信号发送给一个目的进程,而因为目的进程当前正在执行信号 k 的处理程序,所以信号 k 被阻塞了,那么第二个信号就简单地被丢弃了;它不会排队。关键思想是如果存在一个未处理的信号就表明至少有一个信号到达了。

要了解这样会如何影响正确性,来看一个简单的应用,它本质上类似于像 shell 和 Web 服务器这样的真实程序。基本的结构是父进程创建一些子进程,这些子进程各自独立运行一段时间,然后终止。父进程必须回收子进程以避免在系统中留下僵死进程。但是我们还希望父进程能够在子进程运行时自由地去做其他的工作。所以,我们决定用 SIGCHLD 处理程序来回收子进程,而不是显式地等待子进程终止。(回想一下,只要有一个子进程终止或者停止,内核就会发送一个 SIGCHLD 信号给父进程。)

图 8-36 展示了我们的初次尝试。父进程设置了一个 SIGCHLD 处理程序,然后创建了 3 个子进程。同时,父进程等待来自终端的一个输入行,随后处理它。这个处理被模型化为一个无限循环。当每个子进程终止时,内核通过发送一个 SIGCHLD 信号通知父进程。父进程捕获这个 SIGCHLD 信号,回收一个子进程,做一些其他的清理工作(模型化为 sleep 语句),然后返回。

code/ecf/signal1.c

/* WARNING: This code is buggy! */

void handler1(int sig)
{
    int olderrno = errno;

    if ((waitpid(-1, NULL, 0)) < 0)
        sio_error("waitpid error");
    Sio_puts("Handler reaped child\n");
    Sleep(1);
    errno = olderrno;
}

int main()
{
    int i, n;
    char buf[MAXBUF];

    if (signal(SIGCHLD, handler1) == SIG_ERR)
        unix_error("signal error");

    /* Parent creates children */
    for (i = 0; i < 3; i++) {
        if (Fork() == 0) {
            printf("Hello from child %d\n", (int)getpid());
            exit(0);
        }
    }

    /* Parent waits for terminal input and then processes it */
    if ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0)
        unix_error("read");

    printf("Parent processing input\n");
    while (1)
        ;

    exit(0);
}

图 8-36 signal1:这个程序是有缺陷的,因为它假设信号是排队的

图 8-36 中的 signal1 程序看起来相当简单。然而,当在 Linux 系统上运行它时,我们得到如下输出:

linux> ./signal1
Hello from child 14073
Hello from child 14074
Hello from child 14075
Handler reaped child
Handler reaped child
CR
Parent processing input

从输出中我们注意到,尽管发送了 3 个 SIGCHLD 信号给父进程,但是其中只有两个信号被接收了,因此父进程只是回收了两个子进程。如果挂起父进程,我们看到,实际上子进程 14075 没有被回收,它成了一个僵死进程(在 ps 命令的输出中由字符串“defunct”表明):

Ctrl+Z
Suspended
linux> ps t
  PID TTY      STAT   TIME COMMAND
    .            .      .      .
    .            .      .      .
14072 pts/3    T      0:02 ./signal1
14075 pts/3    Z      0:00 [signal1] &lt;defunct>
14076 pts/3    R+     0:00 ps t

哪里出错了呢?问题就在于我们的代码没有解决信号不会排队等待这样的情况。所发生的情况是:父进程接收并捕获了第一个信号。当处理程序还在处理第一个信号时,第二个信号就传送并添加到了待处理信号集合里。然而,因为 SIGCHLD 信号被 SIGCHLD 处理程序阻塞了,所以第二个信号就不会被接收。此后不久,就在处理程序还在处理第一个信号时,第三个信号到达了。因为已经有了一个待处理的 SIGCHLD,第三个 SIGCHLD 信号会被丢弃。一段时间之后,处理程序返回,内核注意到有一个待处理的 SIGCHLD 信号,就迫使父进程接收这个信号。父进程捕获这个信号,并第二次执行处理程序。在处理程序完成对第二个信号的处理之后,已经没有待处理的 SIGCHLD 信号了,而且也绝不会再有,因为第三个 SIGCHLD 的所有信息都已经丢失了。由此得到的重要教训是,不可以用信号来对其他进程中发生的事件计数。

为了修正这个问题,我们必须回想一下,存在一个待处理的信号只是暗示自进程最后一次收到一个信号以来,至少已经有一个这种类型的信号被发送了。所以我们必须修改 SIGCHLD 的处理程序,使得每次 SIGCHLD 处理程序被调用时,回收尽可能多的僵死子进程。图 8-37 展示了修改后的 SIGCHLD 处理程序。

当我们在 Linux 系统上运行 signal2 时,它现在可以正确地回收所有的僵死子进程了:

linux> ./signal2
Hello from child 15237
Hello from child 15238
Hello from child 15239
Handler reaped child
Handler reaped child
Handler reaped child
CR
Parent processing input

code/ecf/signal2.c

void handler2(int sig)
{
    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 = olderrno;
}

图 8-37 signal2:图 8-36 的一个改进版本,它能够正确解决信号不会排队等待的情况

练习题 8.8 下面这个程序的输出是什么?

code/ecf/signalprob0.c

volatile long counter = 2;

void handler1(int sig)
{
    sigset_t mask, prev_mask;

    Sigfillset(&mask);
    Sigprocmask(SIG_BLOCK, &mask, &prev_mask); /* Block sigs */
    Sio_putl(--counter);
    Sigprocmask(SIG_SETMASK, &prev_mask, NULL); /* Restore sigs */

    _exit(0);
}

int main()
{
    pid_t pid;
    sigset_t mask, prev_mask;

    printf("%ld", counter);
    fflush(stdout);

    signal(SIGUSR1, handler1);
    if ((pid = Fork()) == 0) {
        while (1) {};
    }
    Kill(pid, SIGUSR1);
    Waitpid(-1, NULL, 0);

    Sigfillset(&mask);
    Sigprocmask(SIG_BLOCK, &mask, &prev_mask); /* Block sigs */
    printf("%ld", ++counter);
    Sigprocmask(SIG_SETMASK, &prev_mask, NULL); /* Restore sigs */

    exit(0);
}

# 3. 可移植的信号处理

Unix 信号处理的另一个缺陷在于不同的系统有不同的信号处理语义。例如:

  • signal 函数的语义各有不同。 有些老的 Unix 系统在信号 k 被处理程序捕获之后就把对信号 k 的反应恢复到默认值。在这些系统上,每次运行之后,处理程序必须调用 signal 函数,显式地重新设置它自己。
  • 系统调用可以被中断。readwriteaccept 这样的系统调用潜在地会阻塞进程一段较长的时间,称为慢速系统调用。在某些较早版本的 Unix 系统中,当处理程序捕获到一个信号时,被中断的慢速系统调用在信号处理程序返回时不再继续,而是立即返回给用户一个错误条件,并将 errno 设置为 EINTR。在这些系统上,程序员必须包括手动重启被中断的系统调用的代码。

要解决这些问题,Posix 标准定义了 sigaction 函数,它允许用户在设置信号处理时,明确指定他们想要的信号处理语义。

#include <signal.h>

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

返回: 若成功则为 0,若出错则为 -1。

sigaction 函数运用并不广泛,因为它要求用户设置一个复杂结构的条目。一个更简洁的方式,最初是由 W. Richard Stevens 提出的 [110],就是定义一个包装函数,称为 Signal,它调用 sigaction。图 8-38 给出了 Signal 的定义,它的调用方式与 signal 函数的调用方式一样。

Signal 包装函数设置了一个信号处理程序,其信号处理语义如下:

  • 只有这个处理程序当前正在处理的那种类型的信号被阻塞。
  • 和所有信号实现一样,信号不会排队等待。
  • 只要可能,被中断的系统调用会自动重启。
  • 一旦设置了信号处理程序,它就会一直保持,直到 Signal 带着 handler 参数为 SIG_IGN 或者 SIG_DFL 被调用。

我们在所有的代码中实现 Signal 包装函数。