24、信号处理:信号的分类、信号处理函数、异步信号安全函数
信号处理这个话题,说实话,是很多C语言并发程序员容易忽视的角落。我早年做嵌入式系统时,就吃过信号处理的亏——一个没处理好的SIGPIPE信号,让整个服务进程静默退出了,排查了半天才找到原因。嗯,从那以后,我对信号就格外上心。
信号本质上是一种软件中断。它通知进程某个事件发生了。你想想看,操作系统通过信号这种机制,让进程能响应外部或内部的异步事件。说白了,信号就是内核跟用户态进程之间的一种“紧急通知”。
信号的分类
信号分两大类:标准信号和实时信号。标准信号是Unix早期定义的,编号从1到31。实时信号是后来POSIX标准扩展的,编号从SIGRTMIN到SIGRTMAX,通常有31个。
标准信号有个特点:不支持排队。如果同一个标准信号连续来了多次,进程可能只收到一次。我在项目中遇到过这种情况——一个高并发网络服务,多个客户端同时断开连接,SIGPIPE信号蜂拥而至,但进程只处理了其中一次,其他连接的错误就漏掉了。
实时信号就不一样了。它们支持排队,而且可以携带附加数据(通过sigqueue函数发送)。每个实时信号都会按顺序被处理,不会丢失。
常见的标准信号我列一下:
| 信号 | 编号 | 默认行为 | 触发场景 |
|---|---|---|---|
| SIGINT | 2 | 终止进程 | Ctrl+C |
| SIGQUIT | 3 | 终止+core dump | Ctrl+\ |
| SIGKILL | 9 | 终止进程 | kill -9 |
| SIGSEGV | 11 | 终止+core dump | 非法内存访问 |
| SIGPIPE | 13 | 终止进程 | 写已关闭的管道 |
| SIGALRM | 14 | 终止进程 | 定时器到期 |
| SIGTERM | 15 | 终止进程 | kill默认信号 |
| SIGCHLD | 17 | 忽略 | 子进程状态变化 |
信号处理函数
处理信号,传统方式是用signal()函数。但我个人习惯用sigaction(),因为它更可靠、功能更强大。signal()在不同Unix系统上的行为有差异,而sigaction()是POSIX标准定义的,行为一致。
来看一个简单的例子:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void sigint_handler(int signo) {
// 注意:这里只做简单操作
write(STDOUT_FILENO, "\nCaught SIGINT!\n", 16);
}
int main() {
struct sigaction sa;
sa.sa_handler = sigint_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
return 1;
}
printf("Press Ctrl+C to trigger signal...\n");
while (1) {
pause(); // 等待信号
}
return 0;
}
这段代码里,我注册了一个SIGINT的处理函数。当用户按下Ctrl+C时,不会终止进程,而是打印一条消息。注意我用了write()而不是printf()——为什么?后面会讲。
sigaction结构体有三个关键字段:
- sa_handler:信号处理函数指针
- sa_mask:在处理当前信号时,要额外阻塞的信号集
- sa_flags:控制信号行为的标志位,比如SA_RESTART可以让被信号中断的系统调用自动重启
异步信号安全函数
这是信号处理中最容易踩坑的地方。信号处理函数是异步执行的——它可能在程序执行的任何时刻被触发。这意味着,你在信号处理函数里调用的函数,必须是“异步信号安全”的。
什么叫异步信号安全?简单说,就是函数内部不会使用全局或静态的非重入数据结构,不会调用malloc/free这类可能修改全局状态的操作,也不会持有锁。
我曾经犯过一个错误:在信号处理函数里调用了printf()。结果程序偶尔会莫名其妙地卡死。排查了很久才发现,printf内部使用了全局锁,而信号触发时主程序正好也在调用printf,两个printf互相等待,死锁了。
POSIX标准明确规定了哪些函数是异步信号安全的。我常用的几个:
- write():直接系统调用,安全
- read():直接系统调用,安全
- _exit():立即退出,不执行清理,安全
- sig_atomic_t类型的变量读写:原子操作,安全
- signal() / sigaction():设置信号处理函数,安全
不安全的函数就太多了:printf、malloc、free、fprintf、sprintf、任何锁操作、任何可能修改errno的函数(除非你保存和恢复errno)。
来看一个正确的做法:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
volatile sig_atomic_t got_signal = 0;
void handler(int signo) {
// 只做一件事:设置标志
got_signal = 1;
}
int main() {
struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGINT, &sa, NULL);
printf("Waiting for signal...\n");
while (!got_signal) {
// 主循环做实际工作
pause();
}
printf("Signal received, cleaning up...\n");
// 在这里做真正的清理工作
return 0;
}
这个模式很实用。信号处理函数只设置一个标志,主程序在循环中检查这个标志,然后做实际的处理。这样既响应了信号,又避免了在信号上下文里调用不安全的函数。
信号处理的核心逻辑
我把信号处理的整体流程画了一张图,方便你理解:
从这张图可以看出,信号从产生到被处理,中间要经过“递送”和“阻塞判断”两个关键环节。如果信号被阻塞,它会挂起在待处理队列里,直到解除阻塞后才递送。如果没被阻塞,就直接执行处理函数或默认动作。
最后总结一下信号处理的几个要点:
- 用sigaction()代替signal(),行为更可控
- 信号处理函数只做最小操作,设置标志或写简单消息
- 只调用异步信号安全函数,避免printf、malloc等
- 使用volatile sig_atomic_t类型声明共享标志变量
- 考虑使用SA_RESTART标志,减少EINTR错误处理
- 实时信号支持排队和携带数据,适合需要可靠传递的场景
信号处理看起来简单,但细节很多。你想想看,一个异步执行的小函数,如果写得不小心,可能让整个系统崩溃。嗯,多写多练,慢慢就有感觉了。
公众号:蓝海资料掘金营,微信deep3321