11、信号处理安全:信号处理函数的可重入性、竞态条件、安全的信号处理模式
信号处理,在C语言里是个老话题了。但说实话,很多人写信号处理函数的时候,根本没意识到这里面埋了多少雷。我见过不少线上事故,都是信号处理不当导致的。今天咱们就把这块掰开揉碎了讲清楚。
11.1 信号处理函数的核心问题:可重入性
什么叫可重入?说白了,就是一个函数在执行过程中被中断,然后又被重新调用了,这时候还能不能正确运行。能,就是可重入;不能,就是不可重入。
信号处理函数有个特殊的地方——它可以在任何时刻被触发。你想想看,主程序正跑到一半,信号来了,CPU直接跳去执行信号处理函数。如果这个函数里用了全局变量、静态变量,或者调用了不可重入的函数,那就危险了。
- 在信号处理函数中调用 printf、malloc、free 等标准库函数
- 修改全局或静态变量(除非用 volatile sig_atomic_t)
- 使用锁或互斥量(可能造成死锁)
- 执行复杂的业务逻辑
我在项目中遇到过这样一个案例:一个后台服务,信号处理函数里调用了 free() 释放内存。结果有一次主程序正在 malloc 内部操作链表,信号来了,free() 一执行,链表结构被破坏,程序直接崩溃。嗯,这就是典型的不可重入问题。
11.2 竞态条件:信号处理的隐形杀手
竞态条件,说白了就是多个执行流在竞争同一个资源,结果取决于谁先谁后。信号处理中的竞态条件,往往比多线程更隐蔽——因为信号是异步的,你根本不知道它什么时候来。
举个最常见的例子:
volatile sig_atomic_t flag = 0;
void handler(int sig) {
flag = 1;
}
int main() {
signal(SIGINT, handler);
while (!flag) {
// 等待信号
}
printf("收到信号,退出\n");
return 0;
}
这段代码看起来没问题?其实有隐患。如果信号在 while 条件判断之后、但在进入循环体之前到达,那 flag 被置为1了,但程序可能还在循环里多跑一圈。虽然 sig_atomic_t 保证了读写原子性,但逻辑上的竞态条件依然存在。
pause() 或 sigsuspend() 来阻塞等待,而不是自旋轮询。自旋不仅浪费CPU,还容易出竞态问题。
11.3 安全的信号处理模式
那到底该怎么写信号处理函数?我总结了一套模式,这些年一直在用,没出过问题。
模式一:最小化原则
信号处理函数只做一件事——设置标志位。所有实际工作都放到主循环里去做。
volatile sig_atomic_t g_shutdown = 0;
void sigint_handler(int sig) {
g_shutdown = 1; // 只做这一件事
}
int main() {
signal(SIGINT, sigint_handler);
while (!g_shutdown) {
// 正常业务逻辑
}
// 清理资源,优雅退出
return 0;
}
模式二:使用自管道(self-pipe trick)
这个技巧我特别喜欢。它把信号事件转换成文件描述符事件,这样就能用 select() 或 epoll() 统一处理了。
int pipe_fd[2];
void handler(int sig) {
write(pipe_fd[1], "", 1); // 写入一个字节通知主循环
}
int main() {
pipe(pipe_fd);
signal(SIGINT, handler);
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(pipe_fd[0], &rfds);
while (1) {
select(pipe_fd[0] + 1, &rfds, NULL, NULL, NULL);
if (FD_ISSET(pipe_fd[0], &rfds)) {
// 处理信号
break;
}
}
return 0;
}
write(),而 write() 是异步信号安全的。主循环通过 select() 等待,既不会漏掉信号,也不会忙等待。
模式三:信号屏蔽与延迟处理
有时候,某些关键代码段不能被信号打断。这时候就需要屏蔽信号。
sigset_t new_mask, old_mask;
sigemptyset(&new_mask);
sigaddset(&new_mask, SIGINT);
// 屏蔽 SIGINT
sigprocmask(SIG_BLOCK, &new_mask, &old_mask);
// 执行关键代码
// ...
// 恢复原来的信号掩码
sigprocmask(SIG_SETMASK, &old_mask, NULL);
我曾经在一个数据库项目中用过这个模式。当时有个事务提交的临界区,绝对不能被打断。我们用 sigprocmask 把信号屏蔽掉,等事务提交完了再恢复。嗯,效果很好,再也没出过数据不一致的问题。
11.4 信号安全的函数列表
POSIX标准定义了一组异步信号安全的函数。我列个表,大家写信号处理函数时,只调用这些函数就好。
| 类别 | 安全函数 |
|---|---|
| 文件操作 | read, write, open, close, lseek, fstat, stat |
| 进程控制 | fork, execve, _exit, wait, waitpid, getpid, getppid |
| 信号操作 | signal, sigaction, sigprocmask, sigpending, sigsuspend |
| 内存操作 | mmap, munmap, mprotect |
| 其他 | abort, alarm, sleep, usleep, time, gettimeofday |
11.5 知识体系总览
下面这张图,我把信号处理安全的核心知识点串起来了。你一看就明白。
11.6 避坑指南
最后,我把自己踩过的坑总结一下,你们直接拿去用。
- 不要用 signal(),用 sigaction()——signal() 在不同系统上行为不一致,sigaction() 才是 POSIX 标准推荐的。
- volatile 关键字不能少——编译器可能会优化掉对全局变量的读取,加上 volatile 告诉编译器「这变量随时可能变」。
- sig_atomic_t 不是万能的——它只保证读写原子性,不保证逻辑原子性。多个信号同时到达时,还是可能出问题。
- 我曾经犯过一个错——在信号处理函数里调用了
longjmp()跳转到主程序。结果资源没释放,文件描述符泄漏了。后来我改用自管道模式,再也没出过类似问题。
信号处理这块,说白了就是「别在信号处理函数里干太多事」。你把它当成一个门铃——门铃响了,你记下来就行,不用开门去迎接客人。等你有空了,再慢慢处理。
公众号:蓝海资料掘金营,微信deep3321