信号处理的并发安全

信号处理程序 (Signal Handler) 是与主程序并发运行的独立控制流。由于它们共享全局变量,且运行时刻不可预测,这引入了极度危险的并发竞争 (Race Conditions)

1. 核心原则:异步信号安全 (Async-Signal-Safety)

问题: 如果主程序正在调用 malloc(它内部持有锁),此时中断发生,Handler 也调用了 malloc(试图再次获取锁),就会发生死锁。或者如果 Handler 修改了主程序正在读写的全局数据结构,会导致数据破坏。

安全函数: 只有被定义为“异步信号安全”的函数才能在 Handler 中调用。

  • 安全列表: _exit, write, waitpid, sleep, kill 等。
  • 不安全列表(严禁调用): printf, sprintf, malloc, exit (标准库版本)。
    • 技巧: 在 ShellLab 中,如果你想在 Handler 里打印调试信息,必须用 sio_puts (CSAPP 提供的安全包装函数),绝对不能用 printf

保护全局变量:

  • 如果 Handler 和主程序共享全局变量(如 job list),必须使用 sigprocmask 暂时阻塞信号,构建临界区 (Critical Section),防止访问冲突。
  • 共享标志位应声明为 volatile sig_atomic_t,确保读写原子性且不被编译器优化。

2. 隐形陷阱:errno 保存

问题: 许多系统调用(如 read, wait)出错时会设置全局变量 errno。如果主程序刚执行完一个系统调用,检查 errno 之前被中断,而 Handler 里调用的函数修改了 errno,主程序恢复后就会看到错误的错误码。

解决方案:

void handler(int sig) {
    int olderrno = errno; // 1. 进入时保存
    // ... 处理逻辑 ...
    errno = olderrno;     // 2. 退出前恢复
}

3. 信号不排队 (Signals do not queue)

现象: 标准 Unix 信号(1-31)是不排队的。如果你的 Handler 正在处理一个 SIGCHLD,此时又来了 2 个 SIGCHLD,操作系统只会把“Pending 位”置 1。当 Handler 返回时,内核看到 Pending 位,只会再触发一次 Handler。因此,你丢了一个信号。

致命后果: 如果你的 Handler 写成 if (waitpid(...) > 0) reap();,你只能回收一个僵尸。剩下的僵尸因为信号丢失而永远不会被回收。

正确写法(循环回收):

void sigchld_handler(int sig) {
    int olderrno = errno;
    // 使用 while 循环,尽可能多地回收僵尸
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        // 回收成功,从 job list 中删除
    }
    errno = olderrno;
}

4. 竞争条件:Fork 与 AddJob

这是 ShellLab 最经典的 Bug。

错误逻辑:

  1. 父进程 fork()
  2. 子进程结束极快,触发 SIGCHLD。
  3. 父进程还没来得及运行 addjob,就被中断去执行 Handler。
  4. Handler 调用 deletejob
  5. 结果: 先删后加。Job 永远留在了列表里。

正确逻辑(显式阻塞):

sigprocmask(SIG_BLOCK, &mask_all, &prev_all); // 1. 屏蔽信号
if (fork() == 0) {
    sigprocmask(SIG_SETMASK, &prev_all, NULL); // 子进程解除屏蔽
    execve(...);
}
addjob(...); // 2. 此时绝对安全,因为信号进不来
sigprocmask(SIG_SETMASK, &prev_all, NULL); // 3. 解除屏蔽