做Linux下的服务端开发这一行,几乎没有人能躲开信号处理。你可能写过kill -9杀进程,也可能被SIGSEGV段错误折磨到深夜,但真正把signal、sigaction、sigprocmask、sigsuspend这些核心函数挨个吃透的人,却不算多。原因也不难理解:信号机制不是“学会了就能天天用”的东西,很多时候程序跑得好好的,你根本不关心它;可一旦线上出现“进程莫名退出”“子进程变僵尸”“信号处理函数里调 printf 导致死锁”这类问题,再回头翻手册就有点来不及了。
这篇文章就是我整理的 Linux 信号核心函数速查表,把发送、捕获、阻塞、等待、与多路复用结合这几个最关键的环节拆开讲清楚。重点放在函数语义、参数区别、以及新手最容易踩的坑上,适合正在学系统编程的同学,也适合写过一段时间 C/C++ 但总感觉信号这块不成体系的开发者。先给一张总览表,后面逐个展开。
| 函数 | 头文件 | 核心作用 |
|---|---|---|
kill/raise | <signal.h> | 向进程/线程发送信号 |
signal/sigaction | <signal.h> | 注册信号处理函数 |
sigemptyset/sigaddset | <signal.h> | 操作信号集(位图) |
sigprocmask | <signal.h> | 阻塞/解除阻塞信号 |
sigpending | <signal.h> | 查询未决信号 |
sigsuspend | <signal.h> | 原子替换掩码并等待信号 |
sigwait | <signal.h> | 同步等待信号的到来 |
signalfd | <sys/signalfd.h> | 把信号变成文件描述符 |
timer_create | <signal.h>/<time.h> | 创建定时器,到期发信号 |
下面进入正题。
1. 信号机制速览:为什么不能只靠 kill 和 sleep
1.1 信号是什么,内核到底做了什么
信号本质上是一种软件中断。进程收到信号,内核就把这个事件记到进程的pending位图上,等进程从内核态返回用户态之前,检查有没有需要处理的信号。有就触发对应的处理动作:忽略、捕获(调用用户函数)、执行默认行为或者终止进程。
这个机制有几个特点值得注意。
第一,信号是异步的。它不像函数调用那样由你主动发起,而是随时可能发生。所以信号处理函数不能假设“当前执行到哪一行”,它可能发生在malloc内部、可能在printf途中、也可能在一条普通赋值语句中间。这个特性直接决定了信号处理函数能调用什么、不能调用什么。
第二,信号有“标准信号”和“实时信号”的区别。1~31 号是标准信号,不排队,多个相同的信号合并成一个;34~64 号是实时信号,支持排队和携带数据。绝大多数业务场景用的都是标准信号,但你要知道 SIGUSR1、SIGUSR2 这类信号如果短时间内到多个,可能只触发一次,别拿它们当精确计数器。
第三,信号的默认行为并不相同。整理成表格方便记忆:
| 信号 | 默认动作 | 典型触发场景 |
|---|---|---|
SIGINT | 终止进程 | Ctrl+C,终端中断 |
SIGQUIT | 终止并产生核心转储 | Ctrl+\ |
SIGKILL | 强制终止,不可捕获 | kill -9 |
SIGTERM | 终止进程,可捕获 | kill默认发送 |
SIGSEGV | 终止并核心转储 | 非法内存访问 |
SIGCHLD | 忽略 | 子进程停止或结束 |
SIGPIPE | 终止进程 | 写已关闭的管道/socket |
SIGALRM | 终止进程 | alarm()定时器到期 |
SIGUSR1/SIGUSR2 | 终止进程 | 用户自定义事件 |
SIGSTOP/SIGCONT | 停止 / 继续 | 作业控制 |
1.2 为什么“速查”比“精通”更现实
很多人刚接触信号时有个误区:以为signal函数够用了,其他函数以后再看。结果真到项目里,遇到“屏蔽 SIGINT 再恢复”“安全地等待子进程退出”“在 epoll 里监听信号”这些场景,才发现signal一个都解决不了。信号这块内容的特点是“知道有哪些函数、每个函数适合什么场景”远比“背诵函数签名”重要。所以我把速查表拆成三个层次:发送与捕获、信号集与阻塞、同步等待与多路复用。每个层次解决一类问题,你手上的需求属于哪一类,直接跳过去看对应的函数就行。
2. 发送与捕获:kill、raise、signal、sigaction 到底怎么选
2.1 kill 与 raise:发送信号的两个入口
先说发送。kill的原型是:
#include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig);pid参数有几个特殊取值,很多人容易忽略:
pid > 0:发给指定进程。pid == 0:发给同进程组的所有进程。写服务端程序时如果不小心对调用方的进程组发起信号,可能把同一组里的多个进程一并干掉,慎用。pid == -1:发给有权限发送的所有进程,不包括init和自己?实际上会排除一些系统进程,但效果仍然很“猛”,日常代码几乎不该用。pid < -1:发给进程组abs(pid)。
返回值方面,成功返回 0;失败返回 -1,常见错误码有EINVAL(信号编号非法)、EPERM(无权限)、ESRCH(进程不存在)。这里有个实用技巧:用kill(pid, 0)可以检测进程是否存在。注意这个 0 不是“发空信号”的意思,而是“不发送实际信号,只做权限和存在性检查”。如果返回 0 说明进程存在且你有权限;返回 -1 且errno == ESRCH说明进程已经没了。但测完到真正操作之间进程可能退出,存在竞态,不能把这个当成绝对可靠的判断。
raise就简单了:
#include <signal.h> int raise(int sig);等价于在自己的进程里执行kill(getpid(), sig),但区别在于raise发送成功后,信号可能被当前线程直接处理,而不是放到进程级 pending 里。实际开发中如果用不上“向其他进程发信号”的能力,raise更安全、更直观。
2.2 signal:经典但坑多,新代码不推荐
signal的签名是:
typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);用法一看就懂:给某个信号挂一个处理函数。但坑就在这里——不同操作系统对signal的语义不统一。System V 下,信号处理完后恢复默认行为,如果你想接着处理就需要在回调里再次调用signal,中间存在一个时间窗,信号来了可能直接按默认行为终止进程。BSD 下又是另一种语义,自动保持已设置的处理器。可移植性极差。
更麻烦的是,历史上signal不支持设置SA_RESTART。很多系统调用(如read、write、accept、poll)被信号打断后返回EINTR错误。如果你的进程对EINTR没做处理,业务逻辑就会莫名其妙地中断;做了处理又得处处加判断,代码很难看。
所以我个人的观点是:新代码统一用sigaction。signal可以留着维护老项目时看,写新功能不要再用。
2.3 sigaction:完整参数与每个 flag 的真实含义
sigaction是 POSIX 提供的能力完整版:
#include <signal.h> int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact); struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); // 已废弃 };sa_handler和sa_sigaction是共用体,同一时间只能选一个。设sa_handler为SIG_IGN表示忽略,设为SIG_DFL恢复默认行为。如果你想拿到信号的具体来源(发送者 pid、信号值、是否来自定时器等),就要用sa_sigaction,并且把sa_flags里加上SA_SIGINFO。
sa_mask是非常关键的一个字段。它表示“当这个信号的处理函数正在执行时,额外阻塞哪些信号”。注意,正在处理的信号本身通常也会被阻塞,防止嵌套触发。举个例子,你捕获SIGINT,在处理函数里又收到了SIGTERM,如果没设置sa_mask包含SIGTERM,那么SIGTERM可以立刻打断SIGINT处理器。要避免这种情况,就在信号处理函数入口处把SIGTERM加入sa_mask,内核会在进入处理器前临时加锁,等处理器返回后再恢复。
实际的编程中常用:
struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = handle_sigint; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; // 重要:自动重启被中断的系统调用 sigaction(SIGINT, &sa, NULL);这里SA_RESTART一定要解释清楚。它不是“所有系统调用都自动重启”,而是针对“受支持”的调用,比如read、write、accept、ioctl这些,内核在信号处理返回后自动重试一次。poll、select、epoll_wait这类有超时语义的调用不一定会自动重启,尤其是epoll_wait,信号打断就返回EINTR。很多人在生产环境里发现“epoll 明明注册了事件,代码却偶尔卡一下”,查到最后就是没对EINTR做处理。
把sa_flags里常见的几个拎出来:
| flag | 作用 |
|---|---|
SA_RESTART | 系统调用被打断后自动重启(部分调用) |
SA_SIGINFO | 使用sa_sigaction,回调附带siginfo_t |
SA_NOCLDWAIT | 仅用于SIGCHLD,子进程退出后自动回收,不产生僵尸 |
SA_NODEFER | 处理函数执行期间不阻塞当前信号,允许递归触发 |
SA_RESETHAND | 信号处理完后恢复默认行为,相当于 System V 的signal |
oldact参数也值得一说。传入非空oldact,内核会把“旧的信号配置”完整保存。有些项目会用“先捕获旧处理器,最后再恢复”的方式实现优雅插件化,这个参数就是干这个用的。
2.4 实例:用 sigaction 做一个可优雅退出的进程
我写一个最小的示例,演示捕获SIGINT和SIGTERM并做清理:
#include <stdio.h> #include <string.h> #include <signal.h> #include <unistd.h> #include <stdlib.h> static volatile sig_atomic_t g_running = 1; static void handle_term(int sig) { // 只置标志位,绝不在信号处理里做复杂操作 g_running = 0; } int main(void) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = handle_term; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; if (sigaction(SIGINT, &sa, NULL) < 0 || sigaction(SIGTERM, &sa, NULL) < 0) { perror("sigaction"); exit(EXIT_FAILURE); } while (g_running) { printf("working...\n"); sleep(1); } printf("cleaned, bye\n"); return 0; }这里g_running要用volatile sig_atomic_t,因为它在信号处理函数里被修改、在主循环里被读取,两者之间没有同步机制。sig_atomic_t是 ISO C 规定的一个原子整数类型,加上volatile是为了防止编译器把变量优化进寄存器后信号处理函数改了内存里的值,主循环却读不到新值。
3. 信号集与阻塞:sigprocmask / sigpending / sigsuspend 的原子操作逻辑
3.1 信号集:一张位图,三个基础函数
信号集算得上信号处理的基础设施。它的本质就是一张位图,每一位代表一个信号是否在集合里。标准流程是:
sigset_t set; sigemptyset(&set); // 先清空,才能保证没有脏数据 sigaddset(&set, SIGINT); sigaddset(&set, SIGTERM);为什么一定要先sigemptyset?因为sigset_t在不同平台上实现不一样,把栈上未初始化的局部变量直接传给sigaddset,可能残留其他 bit。这个顺序不能省。
对应的还有sigdelset从集合中移除一个信号,sigismember判断信号是否在集合中。这三个函数都是纯内存操作,不需要系统调用。
注意区分“阻塞”和“忽略”的区别。阻塞一个信号,意味着内核把信号记录为 pending,但不会执行处理函数;忽略一个信号,意味着信号到达后直接扔掉。前者可以用sigprocmask撤销阻塞后继续处理这个信号,后者扔掉就真没了。这两者经常混用,实际语义差别很大。
3.2 sigprocmask:进程级阻塞与解除
sigprocmask的原型:
#include <signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);how有三种取值:
SIG_BLOCK:把set里的信号加入当前阻塞集合。SIG_UNBLOCK:把set里的信号从当前阻塞集合中移除。SIG_SETMASK:直接用set替换当前阻塞集合。
oldset用来取出调用之前的旧掩码,以便后边恢复。常见写法:
sigset_t block_set, old_set; sigemptyset(&block_set); sigaddset(&block_set, SIGINT); sigprocmask(SIG_BLOCK, &block_set, &old_set); // 临界区,不希望被 SIGINT 打断 sigprocmask(SIG_SETMASK, &old_set, NULL); // 恢复有个开发上的小细节:在多线程进程里,sigprocmask的行为是未定义的?实际上 POSIX 规定它是进程级的,但 Linux 内核将“阻塞掩码”保存在每个线程自己的 TCB 里。pthread_sigmask才是线程级别的接口。所以在多线程程序里不要用sigprocmask,要用:
#include <pthread.h> int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);参数一模一样。这也是一个非常容易踩坑的点:你在一个线程里想屏蔽某个信号,结果用sigprocmask只影响了调用线程?还是影响了进程?行为跟平台相关,在 glibc 下实际影响调用线程,但手册并不推荐这样用。与其赌平台实现,不如直接写成pthread_sigmask。
3.3 sigpending:检查未决信号
如果某个信号被阻塞了,内核不会立刻执行它的处理动作,而是放到 pending 集合里。sigpending就是用来查这个集合的:
int sigpending(sigset_t *set);成功返回 0,set里得到所有“被阻塞且尚未处理”的信号。这个函数常见的用途是实现“信号是否曾经到达过”的检查。举例:你屏蔽了SIGUSR1,程序跑到某个里程碑点想确认有没有人来过消息,就可以查 pending。
不过要提醒一点:sigpending查到的信号,不代表它一直没有被处理过。如果一个信号到达后已经被处理了,它就不会留在 pending 里。所以它只反映“当前积压着的、来不及处理的信号”,不是“历史记录”。
3.4 sigsuspend:等待信号时的原子操作
sigsuspend是我认为理解门槛最高的一个函数。它的原型很简单:
#include <signal.h> int sigsuspend(const sigset_t *mask);执行过程分三步:先把当前阻塞集合替换成mask,然后挂起进程等待信号,最后收到信号并处理后,恢复原来的阻塞集合并返回 -1,同时errno设为EINTR。
这个函数解决了一个经典的竞态问题:你要“先解除某个信号的阻塞,再等待它发生”,如果分两步做,中间可能错失信号。比如:
sigprocmask(SIG_UNBLOCK, &set, NULL); // 第一步 pause(); // 第二步如果信号在第一步和第二步之间到达,且你没在阻塞它,信号就被正常处理并把 pending 状态置空,然后pause()继续挂起,永远等不到后续信号。对于超时重试、事件同步这类逻辑,这是致命的。而sigsuspend把“替换掩码”和“等待信号”合并成一个原子操作,信号到了才能从挂起中返回,不会丢。
实际用的时候要注意:因为sigsuspend总会返回 -1,所以正常写法是对errno == EINTR做特殊处理,不要当成失败打印错误。
sigset_t mask; sigemptyset(&mask); // 假设之前阻塞了 SIGUSR1,现在只等待它 sigprocmask(SIG_SETMASK, &mask, NULL); // 上面这样是错的,有竞态 sigsuspend(&mask); // 正确 if (errno == EINTR) { // 说明确实被信号打断了,继续业务 }4. 线程里的信号:sigwait、signalfd 与 self-pipe 的现代做法
4.1 信号处理函数能调用哪些函数?先说清楚“异步信号安全”
讨论线程里的信号,必须先回答一个基础问题:信号处理函数里到底能做什么?
答案是:只能调用 async-signal-safe 函数。这个概念比“线程安全”更严格。线程安全只保证多线程并发执行时不产生数据竞争,但信号处理函数可能中断主线程的任意一条指令,哪怕主线程正持着锁。如果信号处理函数里调用了一个同样需要拿锁的函数,而主线程恰好在锁里,就死锁了。
所以最稳妥的做法是:信号处理函数里只做两件事——设置一个volatile sig_atomic_t标志位,或向self-pipe写入一个字节。其他事情全部放到主循环里做。这就是为什么下面的sigwait和signalfd路径逐渐流行:它们把“信号处理”从异步上下文搬到普通上下文,让你可以正常地调用malloc、printf、加锁解锁。
4.2 sigwait:阻塞等待信号而非打断主流程
sigwait的用法是把信号集中到一个地方同步处理:
#include <signal.h> int sigwait(const sigset_t *set, int *sig);调用线程会阻塞,直到set里的某个信号到达。注意,它需要配合pthread_sigmask先把这些信号阻塞掉,否则信号可能会走默认处理逻辑而不是被sigwait捕获。严格说,不同平台的行为不完全一致,但正确姿势就是“先阻塞,再 wait”。
这个模式特别适合“信号处理逻辑比较重”的场景。比如你收到SIGTERM要做服务摘流量、持久化状态、等业务线程收尾,这些操作可能在信号处理函数里做会导致各种诡异问题,放到sigwait里就成了一次普通函数调用,可以放心用锁、用条件变量、用日志库。
还有一个隐藏收益:sigwait返回值里能知道具体收到了哪个信号。这样在一个线程里可以统一处理多个不同的信号,不用为每个信号单独写一个处理函数。
一个注意点:给sigwait设置信号集时,同样的规则也适用。比如set里的信号必须先用pthread_sigmask阻塞,不然信号可能会被其他线程按默认行为处理掉。原因在于“进程发送信号”时选哪个线程来处理,规则比较复杂,但总的来说,被阻塞的信号更有可能被sigwait的线程拿到。
4.3 signalfd:把信号伪装成普通 IO 事件
sigwait适合“专门起一个线程等信号”的模型,但有些服务本来就基于 epoll 事件循环,再额外开线程会让整个架构变重。这种情况下signalfd就很有价值:
#include <sys/signalfd.h> int signalfd(int fd, const sigset_t *mask, int flags);第一次调用时fd传-1,内核返回一个新的文件描述符,后续read这个 fd 就能读到signalfd_siginfo结构体,里面包含信号的编号、发送者 pid、时间戳等信息。
常见组合是:先用sigprocmask或pthread_sigmask阻塞感兴趣的信号,然后创建signalfd,再把 fd 丢给epoll_wait监听可读事件。收到信号后 epoll 会告诉你该 fd 可读,你正常read出来处理。
这样信号处理就彻底融入了 IO 多路复用的流程里,处理函数里可以自由使用堆内存、锁、日志等资源。要注意的是,创建signalfd后信号并不是自动被“消费”掉,你必须认真 read 直到返回EAGAIN,否则会反复触发可读事件,导致 busy loop。
4.4 self-pipe trick:老牌方案背后的原理
在signalfd出现之前,经典的self-pipe trick是在信号处理函数里write一个字节到管道,主循环read这个管道。那样信号处理函数里仍然有一个系统调用,但write本身是 async-signal-safe 的,可以安全使用。
这个方案今天并没有完全过时。如果你用的是比较老的内核,或者代码需要在 BSD、macOS 等多平台编译,signalfd可能不可用,self-pipe依然是最稳妥的思路。它唯一的缺点是:全局不能有多个实例,否则管道会被多处写、随处读,数据容易错配。实现时要给每个事件循环单独分配管道,并且注意管道缓冲区可能写满导致write阻塞——信号处理函数里阻塞是很严重的问题。所以写的时候要做非阻塞处理,或者在信号处理函数里只用write的一个字节,基本不会撑满。
5. 实战整合:定时信号、子进程回收与 epoll 融合
5.1 timer_create + SIGEV_SIGNAL:定时器的信号化
很多人知道alarm()可以发SIGALRM,但alarm只有秒级精度,而且同一时刻只能有一个。如果要做高精度、可区分多个定时器的场景,要用 POSIX 定时器:
#include <signal.h> #include <time.h> int timer_create(clockid_t clockid, struct sigevent *sevp, timer_t *timerid);sevp里可以设置sigev_notify = SIGEV_SIGNAL,这样定时器到期时会给进程发指定信号。更细的配置还能通过sigev_value.sival_ptr带一个指针,在siginfo_t的si_value里拿到。
不过要强调:即使使用了SIGEV_THREAD让内核起线程执行回调,回调函数里依然要小心锁的问题,因为定时器线程和你自己的业务线程并发,锁的粒度如果控制不好,照样会死锁。真正常见组合是timer_create+signalfd:定时器到期发信号,信号进入 signalfd,epoll 读取后执行对应回调。这样定时事件和 IO 事件统一进事件循环,代码结构很干净。
5.2 SIGCHLD 与 waitpid:别让子进程变僵尸
开发者刚开始写多进程服务时,最容易遗漏的是SIGCHLD。子进程退出后父进程没调用waitpid,子进程就变成僵尸。僵尸进程不占 CPU,但占pid,积累多了fork会失败。
捕获SIGCHLD时不要只waitpid一次就完事。因为同一时刻可能多个子进程一起退出,SIGCHLD是一个标准信号,可能合并成一次触发。正确做法是在处理函数里循环:
while (waitpid(-1, &status, WNOHANG) > 0) ;这里WNOHANG是必要的,否则进程可能阻塞在waitpid上。不过,很多人也发现了:在信号处理函数里循环waitpid没问题,因为waitpid是 async-signal-safe 函数;如果你用sigwait或signalfd集中处理,那更轻松,可以在主业务线程里随便写逻辑。
另一种更省心的方案是sigaction的SA_NOCLDWAITflag。设置了它之后,子进程退出自动被内核回收,父进程无需调用waitpid。代价是你再也没法获取子进程的退出码。如果只是“不想留僵尸”,这个 flag 很省事;如果需要拿到退出状态做处理,就老老实实写SIGCHLD处理。
5.3 把信号接进 epoll 事件循环:一个值得直接抄的骨架
这里给出一个可直接运行的骨架,把signalfd、epoll、timer_create整合到一起。为了简洁,省略了错误检查,但实际代码里这段不能省。
#include <sys/epoll.h> #include <sys/signalfd.h> #include <signal.h> #include <stdio.h> #include <unistd.h> #include <string.h> #include <stdint.h> #include <stdlib.h> static int make_signalfd(void) { sigset_t mask; sigemptyset(&mask); sigaddset(&mask, SIGINT); sigaddset(&mask, SIGTERM); sigaddset(&mask, SIGCHLD); sigprocmask(SIG_BLOCK, &mask, NULL); return signalfd(-1, &mask, SFD_NONBLOCK); } int main(void) { int sfd = make_signalfd(); int epfd = epoll_create1(0); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = sfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev); for (;;) { struct epoll_event events[8]; int n = epoll_wait(epfd, events, 8, -1); if (n < 0 && errno == EINTR) continue; for (int i = 0; i < n; i++) { if (events[i].data.fd == sfd) { struct signalfd_siginfo fsi; ssize_t r; while ((r = read(sfd, &fsi, sizeof(fsi))) > 0) { if (fsi.ssi_signo == SIGINT || fsi.ssi_signo == SIGTERM) { printf("got exit signal\n"); return 0; } else if (fsi.ssi_signo == SIGCHLD) { while (waitpid(-1, NULL, WNOHANG) > 0) ; } } } } } return 0; }注意这段代码用了SFD_NONBLOCK,读取时只要还有数据就一直读,直到返回 -1 且errno == EAGAIN。这样才能保证 epoll 不会因为事件没消费完而频繁触发可读事件。另外signalfd_siginfo里的ssi_signo字段是信号编号,读取时一定要检查read返回的长度是否等于sizeof(fsi),如果短了说明数据结构版本不匹配,就不能继续解析。
5.4 高频信号下的性能与正确性权衡
信号机制本身并不慢,慢的是“处理动作”。如果你在信号处理函数里做过多操作,每次信号到来都会打断主线程的指令流水线,轻则缓存污染,重则引入死锁。高频场景我总结了几条经验:
- 只在信号处理函数里置标志位或写
self-pipe,真正的业务逻辑放主循环。 - 使用
signalfd后,信号就从异步上下文解脱出来,可以放心批量读取、批量处理。 - 如果信号量特别大(比如每秒几万个
SIGUSR1),优先考虑别的 IPC 机制,比如消息队列、socketpair。标准信号本身可能合并、可能丢,指望它不丢本来就是错的方向。 - 实时信号虽然排队,但排队有上限,超过队列长度照样丢信号,高频场景不可依赖它做可靠传输。
6. 高频问题与现场排查方法
6.1 段错误信号把问题掩盖了?
程序崩溃时很多时候会显示SIGSEGV,但如果你在程序里注册过SIGSEGV的处理器,就可能导致正常的段错误没能触发 core dump,反而在信号处理函数里死循环或二次崩溃。排查问题时第一件事就是看ulimit -c和/proc/sys/kernel/core_pattern,确认 core dump 是否打开。如果项目里有对SIGSEGV做清理的逻辑,调试时可以先临时注释掉,方便拿到原始崩溃现场。
6.2 信号处理函数卡死主流程
我曾碰到一个特诡异的现象:重启服务时偶尔能正常退出,偶尔直接 hang 住。后来定位到是SIGTERM的处理函数里调用了free,而主线程当时正在malloc内部,两个线程同时操作堆管理的锁,直接死锁。这种现象现场非常难查,因为崩溃时的调用栈可能完全看不出“信号处理函数在等待锁”。排查建议是:在信号处理函数里绝对不要调用malloc、printf、syslog这类常规函数;如果确实需要做这些事,就把信号处理挪到sigwait或signalfd里。
6.3 EINTR 被忽略的隐患
系统调用被信号打断返回EINTR,很多新手的代码不检查这个错误,直接往下走,结果就是明明accept没有新连接却返回了一个无效 fd,或者read明明没数据却被当成读到 0 字节处理导致业务提前结束。排查方法其实很简单:在所有可能返回EINTR的系统调用处,对errno做一次判断,必要时重新调用。用sigaction加SA_RESTART能覆盖一部分调用,但epoll_wait、poll这类超时语义明确的调用,大概率还是要手动处理。
6.4 子进程退出却没有收到 SIGCHLD
如果父进程里已经用sigaction设置了SIGCHLD为SIG_IGN,或者设置了SA_NOCLDWAIT,那么内核会直接回收子进程,父进程自然收不到信号。另一个情况是:在信号处理函数里waitpid只执行了一次,而多个子进程同时退出,导致剩余子进程仍然留在僵尸状态。处理办法就是前面说的循环回收。
6.5 快速定位信号的现场技巧
调试信号问题,我最常用的三件套:
kill -l查看当前系统支持的信号列表。gdb里用handle SIGUSR1 nostop noprint控制信号是否让程序停住,避免调试时信号干扰。strace -e signal查看进程实际收到的信号和系统调用之间的时序,很多“信号太早到达”“信号没送达”的问题能一眼看出来。
/proc 文件系统里也藏着不少信息:
/proc/<pid>/status里的SigBlk、SigIgn、SigCgt分别是阻塞、忽略、捕获的信号位图。/proc/<pid>/stat里的信号相关字段可以辅助判断。
排查时先用这些数据确认“信号到底到没到进程”,再确认是不是被阻塞、被忽略,就能避免在错误方向浪费时间。
6.6 信号相关错误码速查
| 错误码 | 含义 | 常见场景 |
|---|---|---|
EINTR | 系统调用被信号打断 | read、write、epoll_wait返回 -1 |
EINVAL | 信号编号非法 / 参数有误 | sigaction传入无效sig |
EPERM | 无权限发送信号 | 对不属于自己的进程调用kill |
ESRCH | 目标进程不存在 | kill(pid, 0)探测失败 |
回头看,信号其实不是一个“难”的知识点,但它的坑在于细节太多、触发时机不固定,平时不显山露水,出问题就是线上事故。如果把sigaction、sigprocmask、sigsuspend、sigwait、signalfd这几个函数用熟,再把“信号处理函数要短、要原子、不碰锁”的原则刻在脑子里,大部分日常开发里的信号问题都能在五分钟内想明白。
我个人在做服务端框架时,最省心的组合就是“信号全部阻塞 + signalfd + epoll”,再配合sigaction处理个别无法用 signalfd 覆盖的边缘情况。这套方案既绕过了异步信号安全函数的长长列表,又让信号处理逻辑能按正常业务代码的方式去写,调试时还能直接打日志。建议你也找一个小工具项目练一练,比如写一个支持优雅退出的定时任务服务,把signalfd和timer_create接进同一个事件循环。跑通一次,比死记十篇文档都管用。