信号处理的并发安全
信号处理程序 (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。
- 技巧: 在 ShellLab 中,如果你想在 Handler 里打印调试信息,必须用
保护全局变量:
- 如果 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。
错误逻辑:
- 父进程
fork()。 - 子进程结束极快,触发 SIGCHLD。
- 父进程还没来得及运行
addjob,就被中断去执行 Handler。 - Handler 调用
deletejob。 - 结果: 先删后加。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. 解除屏蔽