写Linux下服务端程序这些年,我发现自己跟“信号”(signal)打交道的次数,比跟任何高级框架都多。最典型的一次线上事故:后端进程在凌晨被OOM Killer杀掉,可我们在日志里死活找不到崩溃点,后来排查才发现,进程压根没处理SIGTERM,系统给足了它优雅退出的机会,它却连个善后动作都没做。从那以后,我把Linux系统编程里的信号机制从头捋了一遍,把踩过的坑、看过的内核行为、日常调试手段都沉淀成了一套笔记。
这篇内容适合正在写服务端、嵌入式、或者任何需要跟进程生命周期打交道的Linux C/C++开发者。不绕弯子,直接从信号机制的本质讲起,再到处理函数怎么装才安全、信号怎么发才不丢、多线程下信号归谁管、线上怎么排查信号问题。全文围绕我在实际项目中亲身验证过的结论来写,你照着做基本能避开绝大多数信号相关的雷。
1. 信号的底层直觉:它更像“回合制游戏里的邮件通知”
1.1 信号的三条来源通道
很多教材喜欢把信号叫做“软件中断”,这个词容易误导人。它并不真的像硬件中断那样,能在任何指令边界立刻跳转执行你的处理函数。信号在Linux里的投递时机,更像是一个固定回合的邮件通知:内核把事件放进进程的“信箱”,等进程从内核态返回用户态的那一刻,才被统一检查并派发。
信号的来源基本就三类。第一类是硬件异常,比如除零、访问非法内存,CPU在执行指令时触发了异常,内核会把这个异常包装成SIGFPE、SIGSEGV之类再投递给你。第二类是终端按键,你在终端按Ctrl+C、Ctrl+\、Ctrl+Z,分别产生SIGINT、SIGQUIT、SIGTSTP,发给前台进程组。第三类就是另一个进程或者当前进程自己,通过kill、sigqueue、raise等系统调用,主动向目标进程发送一个信号。
理解这一点,你就明白了为什么一个信号处理函数可以在“任意时刻”被调用——因为主程序执行到任意一条普通指令时,都可能碰到一次用户态/内核态切换,然后就被信号打断了。这种异步插入特性,是后面所有信号编程坑的根源。
1.2 默认动作:没装处理函数时,信号怎么处置进程
每个信号都有一套默认行为。你什么都不管的时候,内核按信号编号对应的默认动作处理进程,大体分五类:终止进程(Term)、终止并生成core文件(Core)、暂停进程(Stop)、恢复运行(Cont)、忽略(Ign)。
| 信号 | 编号 | 默认动作 | 常见触发场景 |
|---|---|---|---|
| SIGHUP | 1 | Term | 终端挂断或会话leader退出 |
| SIGINT | 2 | Term | Ctrl+C |
| SIGQUIT | 3 | Core | Ctrl+\ |
| SIGILL | 4 | Core | 非法指令 |
| SIGABRT | 6 | Core | abort() |
| SIGFPE | 8 | Core | 除零等算术异常 |
| SIGKILL | 9 | Term | 强制杀死进程 |
| SIGUSR1 | 10 | Term | 用户自定义用途 |
| SIGSEGV | 11 | Core | 非法内存访问 |
| SIGUSR2 | 12 | Term | 用户自定义用途 |
| SIGPIPE | 13 | Term | 写向已关闭的管道/Socket |
| SIGALRM | 14 | Term | alarm()定时到期 |
| SIGTERM | 15 | Term | kill默认发出的“请退出”信号 |
| SIGCHLD | 17 | Ign | 子进程停止或退出 |
| SIGCONT | 18 | Cont | 继续执行被暂停的进程 |
| SIGSTOP | 19 | Stop | 暂停进程 |
| SIGTSTP | 20 | Stop | Ctrl+Z |
这里有个特别重要的边界:SIGKILL和SIGSTOP是两个“特权信号”,它们不允许被捕获、阻塞或忽略。你可以理解为内核的最后手段——SIGKILL用来物理消灭进程,SIGSTOP用来强制暂停进程。程序里装任何handler都没用,这也是为什么有些服务即使挂着清理逻辑,一旦被kill -9也留不下任何善后痕迹。
提示:
kill -9能解决一切普通信号解决不了的问题,但它同样不给你任何机会。生产环境的主进程最优先要装的handler其实是SIGTERM,因为它是kill命令的默认信号,代表“系统请你体面退出”。
2. 安装处理函数:为什么我彻底弃用了signal()
2.1 signal()的历史遗留:同样一个函数,两套行为
第一次写信号处理时,大家肯定都是图省事直接用signal(SIGINT, handler)。问题来了:POSIX标准并没有固定signal()的语义,历史上存在System V和BSD两套实现,两者差异非常大。
System V的signal():处理函数执行完之后,这个信号的处置方式会被重置为默认值。这就有个竞态窗口:假设handler正在跑,此时又来了第二个SIGINT,因为信号处理方式已被重置,默认行为是终止进程,你的handler还没跑完,进程就死了。
BSD的signal():处理函数执行完不会重置,而且被信号打断的慢速系统调用会自动重启。
glibc在Linux上实现了BSD语义,看起来似乎够用。但问题在于,signal()无法设置更多属性,比如无法精确控制handler执行期间的屏蔽信号集合,也无法决定系统调用是否要自动重启。你依赖它的行为,本质上依赖glibc的实现细节,而不是POSIX承诺。
我刚入行时就被System V语义坑过一回,后来把项目里所有signal()调用全换成了sigaction(),再没出过同类问题。现在的建议非常明确:新代码一律使用sigaction(),signal()只配出现在教科书里。
2.2 sigaction()参数逐项拆解
sigaction()长这样:
#include <signal.h> int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);其中struct sigaction是最核心的部分:
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_mask:在handler执行期间,额外屏蔽的信号集合。注意,当前正在处理的信号默认会自动屏蔽,这是内核帮你加的,除非你显式设置了SA_NODEFER。sa_flags:常用标志位有SA_RESTART(被打断的系统调用自动重启)、SA_SIGINFO(改用sa_sigaction接收更多上下文)、SA_NOCLDWAIT(SIGCHLD不产生僵尸进程)等。
一个实际的“按3次Ctrl+C才退出”的demo:
#include <stdio.h> #include <signal.h> #include <string.h> #include <stdlib.h> #include <unistd.h> static volatile sig_atomic_t count = 0; static void handler(int sig) { (void)sig; count++; // 只更新标志,不做别的 } int main(void) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = handler; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; if (sigaction(SIGINT, &sa, NULL) < 0) { perror("sigaction"); exit(1); } while (count < 3) pause(); // 一直挂起等信号 printf("received 3 SIGINT, exiting\n"); return 0; }实测运行:不按Ctrl+C之前进程一直挂着,按够3次正常退出。SA_RESTART让pause()的行为更可预期,同时避免慢速系统调用被打断后返回EINTR。
2.3 处理函数执行完就“恢复默认”:最隐蔽的坑
在没有SA_RESTART的情况下,如果进程正阻塞在read()等慢速系统调用上,收到信号后read()会返回-1并设置errno为EINTR。很多新手以为信号处理完就自动接着读了,结果程序莫名退出或者少读一段数据。
char buf[64]; ssize_t n = read(STDIN_FILENO, buf, sizeof(buf)); if (n < 0) { // 如果 errno == EINTR,说明是被信号打断的 if (errno == EINTR) printf("read interrupted by signal\n"); }解决办法有两个层面。第一个层面是在sigaction里加SA_RESTART,让常见系统调用自动重启;第二个层面是业务代码本身要对EINTR做循环重试,因为有些系统调用即使设置了SA_RESTART也依然可能返回EINTR,比如poll()。
我见过不少线上服务,主循环里poll()被信号打断后没有重试,结果进程空转,业务量暴跌。记住一条原则:处理信号不是只装好handler就完了,还要处理系统调用被打断的后果。
3. 给进程“递条子”:kill、raise与带数据的sigqueue
3.1 kill函数的进程组语义和权限边界
kill虽然名字杀气腾腾,但实际作用是“向进程或进程组发送信号”,并不代表杀死。
int kill(pid_t pid, int sig);pid参数的几种取值,坑很多:
pid > 0:发送给指定进程。pid == 0:发送给与当前调用者同进程组的所有进程。pid == -1:发送给调用者有权限发送的所有进程,非常危险,脚本里误用可能波及一堆服务。pid < -1:发送给进程组ID等于-pid的所有进程。
权限检查的内核逻辑:发送者的real或effective UID必须等于目标进程的UID,或者拥有CAP_KILL权限。另外,向PID 1(init)发送SIGKILL这类“终极信号”,现代Linux也会有特殊保护,不是有root就一定能杀掉。
这段代码演示了父进程用kill()通知子进程:
#include <stdio.h> #include <signal.h> #include <unistd.h> #include <sys/wait.h> int main(void) { pid_t pid = fork(); if (pid == 0) { /* 子进程 */ for (;;) pause(); } /* 父进程 */ sleep(1); kill(pid, SIGTERM); /* 给子进程发“请退出” */ wait(NULL); return 0; }3.2 raise发信号给自己:测试handler的最佳搭档
raise(sig)等价于kill(getpid(), sig),但更简洁。它经常被用在测试场景里,让进程在可预测的位置触发一次信号,验证handler是否符合预期。还有一点容易忽略:signal(SIGINT, SIG_IGN)表示忽略信号,而不是捕获信号。被忽略的信号事件根本不会递送给handler,相当于你压根不想管它。用SIG_IGN忽略SIGPIPE是服务端最常见的应对手段,后面第7章会再详细说。
3.3 sigqueue:标准信号丢包,实时信号才带得动“私货”
普通kill()只发一个信号编号,带不了数据。如果业务上希望信号本身捎带一个整数或指针,就得用sigqueue()。
int sigqueue(pid_t pid, int sig, const union sigval value);union sigval可以装一个整数或指针。但注意,sigqueue()只对实时信号有效,也就是信号编号要在SIGRTMIN和SIGRTMAX之间。标准信号(比如SIGUSR1)用sigqueue()传数据,行为是未定义的,且由于标准信号不排队,发多次也可能合并成一次。
这里是一个父进程给子进程通过实时信号传整数的完整例子:
#include <stdio.h> #include <signal.h> #include <unistd.h> #include <string.h> #include <stdlib.h> #include <sys/wait.h> static void rt_handler(int sig, siginfo_t *si, void *uctx) { (void)sig; (void)uctx; printf("child received value: %d\n", si->si_value.sival_int); } int main(void) { pid_t pid = fork(); if (pid == 0) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_sigaction = rt_handler; sa.sa_flags = SA_SIGINFO | SA_RESTART; sigemptyset(&sa.sa_mask); sigaction(SIGRTMIN + 1, &sa, NULL); for (;;) pause(); } /* 父进程 */ sleep(1); union sigval sv; sv.sival_int = 42; sigqueue(pid, SIGRTMIN + 1, sv); sleep(1); kill(pid, SIGTERM); wait(NULL); return 0; }实际跑一下,子进程能打印出“received value: 42”。这个机制适合做轻量级的状态同步,比如通知工作线程“队列积压数已经达到阈值,这个阈值作为参数传过去”。
4. 阻塞、未决与排队:标准信号“容易丢”的真相
4.1 sigprocmask:为什么业务逻辑需要暂时屏蔽信号
有时候,进程正处在某个不允许被插队的关键区段,比如正在更新一段复杂的内存结构、正在写数据库事务日志,如果突然冒出一个SIGTERM handler去访问同一批资源,后果不堪设想。
这种场景需要把信号先“压住”,等关键区段结束再放行。用sigprocmask():
sigset_t newset, oldset; sigemptyset(&newset); sigaddset(&newset, SIGINT); sigaddset(&newset, SIGTERM); sigprocmask(SIG_BLOCK, &newset, &oldset); /* 这里处于关键区段,SIGINT/SIGTERM只会变成未决,不会打断 */ sigprocmask(SIG_UNBLOCK, &newset, NULL);三个操作方式:SIG_BLOCK(往当前屏蔽集里追加)、SIG_UNBLOCK(移除)、SIG_SETMASK(设置为指定集合)。记住:阻塞不是忽略。忽略是信号来了直接扔进垃圾桶;阻塞只是把信号扣在邮箱里,等你解除阻塞再投递。
4.2 一个可复现实验:标准信号真的会合并
下面这段代码是验证“标准信号不排队”最直观的方法。
#include <stdio.h> #include <signal.h> #include <unistd.h> #include <string.h> #include <stdlib.h> static void handler(int sig) { (void)sig; write(STDOUT_FILENO, "got it\n", 7); /* 异步安全 */ } int main(void) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = handler; sa.sa_flags = SA_RESTART; sigemptyset(&sa.sa_mask); sigaction(SIGINT, &sa, NULL); sigset_t set, oldset; sigemptyset(&set); sigaddset(&set, SIGINT); sigprocmask(SIG_BLOCK, &set, &oldset); printf("please press Ctrl+C five times...\n"); sleep(5); sigset_t pending; sigpending(&pending); if (sigismember(&pending, SIGINT)) printf("SIGINT is pending\n"); sigprocmask(SIG_UNBLOCK, &set, NULL); sleep(1); return 0; }实验过程:程序先把SIGINT阻塞,你疯狂按Ctrl+C,然后解除阻塞。结果会让你意外——handler只执行了一次,不是五次。原理很简单:未决信号在内核里是用一个位图表示的,每个标准信号只占一个bit。只要这个信号已经“挂号”了,后面再来的同款信号就直接丢弃,直到它被处理掉。
4.3 实时信号如何做到排队保序
实时信号之所以“实时”,体现在三件事:每个信号都有独立的排队槽位、允许携带额外数据、多个同类型信号不会被合并。这样就能保证你发10次SIGRTMIN+1,处理函数就老老实实被调用10次,而且按照发送顺序来。
| 特性 | 标准信号 | 实时信号 |
|---|---|---|
| 信号编号范围 | 1~31(传统) | SIGRTMIN~SIGRTMAX(通常34~64) |
| 多次发送是否合并 | 会合并,只记一次 | 不会合并,逐个排队 |
| 是否携带额外数据 | 不带 | 可用sigqueue携带整数或指针 |
| 递送顺序 | 不保证 | 同类型按发送顺序 |
| 队列容量 | 有限 | 受RLIMIT_SIGPENDING限制 |
实际操作中,SIGRTMIN在glibc里可能是个动态值,不要硬编码数字,始终用SIGRTMIN + n来引用自定义实时信号。另外,排队信号也会受RLIMIT_SIGPENDING限制,如果队列溢出发送方会收到EAGAIN,代码里不能忽略这个返回值。
5. 处理函数里的“安全区”:为什么不能随便调用printf
5.1 printf和malloc在信号处理函数里引发的死锁
这可能是信号编程里最阴间的问题。表面上看,你只是想在收到信号时打印一行日志,于是写了:
static void handler(int sig) { printf("got signal %d\n", sig); }灾难往往发生在不经意间。假设主程序正在调用printf(),它已经拿到了stdout内部的锁,然后打印到一半,信号来了,处理器去跑你的handler,handler里又调printf(),再次去拿同一把stdout锁——死锁。
malloc()同理。主程序可能在malloc()内部操作堆管理链表时持有堆锁,信号处理函数里再malloc()就卡死。我见过不止一个服务因为这种写法,表现为“收到信号后进程不退出也不响应,CPU却是0%”,最后用gdb一挂上去,栈全在锁等待上。
5.2 黄金法则:handler只置标志、写管道,不干重活
POSIX标准维护了一张async-signal-safe函数清单,里面只有sig_atomic_t级别的操作、read、write、open、close、_exit等极少数函数是保证安全的。而printf、sprintf、malloc、free、syslog、pthread_*之类全都不在清单里。
所以我的做法通常是这样:handler里只更新一个volatile sig_atomic_t标志位,然后通过一个预先打开的pipe向主循环发一个字节通知。主循环用poll或epoll监听这个pipe,收到通知后再统一处理。
static int notify_fd = -1; static void handler(int sig) { (void)sig; unsigned char ch = 1; ssize_t ignored = write(notify_fd, &ch, 1); (void)ignored; }这种设计把信号处理里的“异步爆炸”转变成了主循环的“同步轮询”,无论是加锁、分配内存、写数据库,放主循环里干都好办得多。如果你实在需要一个可靠的日志记录,请用write往已打开的fd写,不要碰printf族函数。
5.3 volatile sig_atomic_t与errno:两个更小的细节
第一个小细节,共享标志位必须用volatile sig_atomic_t。普通变量会被编译器优化到寄存器里,主循环不断读,可能永远读不到handler更新的值。带上volatile让每次读都落到内存。sig_atomic_t在x86上就是int,读写是原子的,但它只能保证“单个读或写是原子的”,不保证自增操作原子,比如count++就不是安全的。
第二个小细节,errno是线程局部存储,handler里的任何安全函数都可能修改errno,导致处理返回后主程序的错误判断被污染。正确的做法是进入handler先保存errno,退出前恢复:
static void handler(int sig) { (void)sig; int saved_errno = errno; /* ... 中间只做安全操作 ... */ errno = saved_errno; }这个小习惯在调试边缘性bug时能省很多时间。
6. 多线程与信号:信号到底发给哪个线程
6.1 线程组信号投递规则
信号编程进入多线程时代后,最让人困惑的就是“kill(pid, sig)到底发给谁”。答案是:kill()产生的是进程级信号,内核会在这个进程的所有线程里,挑一个没有阻塞该信号的线程来投递。如果所有线程都阻塞了,信号就留在进程级的未决集合里。
而pthread_kill(thread, sig)是线程级信号,只投递给指定线程。此外,同步信号(比如SIGSEGV、SIGFPE这种由CPU异常产生的)总是投递给触发异常的线程。这就意味着,SIGSEGV的handler可能跑在任意线程里,而且就是肇事线程,这点对多线程崩溃时保存现场信息非常重要。
6.2 把所有信号逻辑挪进专用线程:sigwait方案
既然handler会在任意线程异步执行,而多线程程序里资源往往有线程归属,那最稳的思路就是:别让信号handler在随机线程里跑,而是用一个专门的信号处理线程来同步等待信号。
做法分三步:
- 创建任何线程之前,先
pthread_sigmask(SIG_BLOCK, &set, NULL)把要处理的信号全部阻塞住。子线程会继承这个屏蔽集。 - 创建一个专用线程,在循环里调用
sigwait()或sigwaitinfo()同步接收信号。 - 收到的信号按普通业务逻辑处理,不再受异步安全性限制。
#include <stdio.h> #include <signal.h> #include <pthread.h> static sigset_t sig_set; static void signal_worker(void) { int sig; for (;;) { if (sigwait(&sig_set, &sig) != 0) continue; printf("thread %lu handled signal %d\n", (unsigned long)pthread_self(), sig); if (sig == SIGTERM) break; } } int main(void) { sigemptyset(&sig_set); sigaddset(&sig_set, SIGTERM); sigaddset(&sig_set, SIGINT); pthread_sigmask(SIG_BLOCK, &sig_set, NULL); pthread_t tid; pthread_create(&tid, NULL, (void *(*)(void *))signal_worker, NULL); pthread_join(tid, NULL); return 0; }用sigwait()处理信号最大的好处是,你可以在正常线程上下文里尽情使用锁、malloc、日志库,不用担心异步信号安全性。它把“异步通知”转化成了“同步等待”,心智负担直线下降。
6.3 Linux特有的signalfd:把信号变成文件描述符
Linux还提供了一个更契合事件循环的接口signalfd(),可以把信号集合绑定到一个文件描述符上。进程先用sigprocmask阻塞信号,再用read()从这个fd读取信号事件,或者把它丢进epoll,让事件循环统一管理。
#include <sys/signalfd.h> int sfd = signalfd(-1, &sig_set, SFD_NONBLOCK); /* 然后 sfd 交给 epoll 监听 */这种方式对用epoll写高并发服务的人特别友好。信号来了就和网络事件一样,走统一的事件分发路径,不会再出现“某线程莫名其妙被信号打死”的诡异局面。不过要清楚,signalfd是Linux特有接口,不是POSIX,跨平台项目需要做好隔离层。
7. 调试信号问题的三板斧与线上信号故障复盘
7.1 strace:看进程到底收到了什么信号
排查线上问题时,第一步永远是确认进程收到了什么信号、从哪发来的。strace可以挂在进程上看信号:
strace -p <pid> -e trace=signal输出里会看到类似--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=12345} ---的片段。si_pid告诉你这个信号是谁发的,si_code是SI_USER说明来自用户态kill调用,是SI_KERNEL则多半来自内核或硬件异常。
如果排查的是“进程突然退出但没有core”,先用dmesg | tail看有没有OOM Killer的记录,再用strace确认最后收到的信号。
7.2 gdb handle:让信号成为断点而不是屠刀
调试信号处理逻辑时,你往往希望信号到达的瞬间让程序停下来,而不是触发handler直接继续。进入gdb后,用handle命令控制信号:
(gdb) handle SIGTERM stop print nopass (gdb) continuestop表示收到SIGTERM时gdb停住,nopass表示停在信号处理之前、不把信号交给程序。这样你就有机会在信号处理的现场做bt、看寄存器、检查变量。如果是SIGSEGV,也可以先handle SIGSEGV stop nopass,然后再continue,崩溃瞬间停在肇事指令位置,能直接定位是空指针还是越界。
用这套方法,我处理过好几次“偶尔段错误但复现不了”的问题。信号不是玄学,只是需要正确的抓取时机。
7.3 /proc/PID/status:看一眼进程的信号屏蔽状态
很多时候不用上gdb,直接读/proc/<pid>/status就知道进程对信号的处置情况:
SigPnd: 0000000000000000 ShdPnd: 0000000000000000 SigBlk: 0000000000000000 SigIgn: 0000000000001000 SigCgt: 0000000000000002每一行都是一个64位的十六进制掩码,其中最低位是信号1。比如SigIgn值是0x1000,换算二进制第13位为1,也就是信号13(SIGPIPE)被忽略了。SigCgt为0x0002表示信号2(SIGINT)被安装了handler。我自己常用一个小Python脚本换算:
def decode(hex_str): val = int(hex_str, 16) sigs = [i for i in range(1, 65) if val & (1 << (i - 1))] return sigs查SigBlk能确认进程有没有在某个环节悄悄后台阻塞了信号,查SigCgt能确认handler是否真的装上了。有一次同事说“我明明装了SIGTERM handler”,一查SigCgt是0,原来装完马上又被某初始化库重置了。
7.4 三种高频线上故障的根因与对策
故障一:进程变成僵尸。子进程退出后,父进程没有调用wait()回收,并且父进程没有处理SIGCHLD。对策是安装SIGCHLD的handler,在handler里面用waitpid(-1, NULL, WNOHANG)循环收割所有已退出的子进程;如果业务上不要子进程,可以直接signal(SIGCHLD, SIG_IGN),自动回收。
故障二:进程“静默死亡”或突然无输出。很多情况下是SIGPIPE惹的祸——对端关闭了连接或管道,你这边继续write(),内核直接给进程发SIGPIPE,默认动作为终止进程。对策:网络服务里signal(SIGPIPE, SIG_IGN),然后让send()返回EPIPE错误码;使用send()时可以加MSG_NOSIGNAL标志,或者自己处理EPIPE。
故障三:SIGSEGV崩溃但没有core文件。先看ulimit -c是否为unlimited,然后确认/proc/sys/kernel/core_pattern路径可写。很多容器环境下默认禁止写core,需要专门挂载卷或者改用gdb对外采集。生产的崩溃现场很有价值,一定提前把core链路验证好,不要等到事故发生时再研究。
我在实际项目里踩过太多信号的坑,总结下来核心思路就一条:能把信号转成普通的事件循环事件,就不要依赖在随机线程里异步执行的handler;handler里只允许做最轻量的事,其他全推给主流程。按这个原则写信号处理代码,绝大多数诡异问题都能从源头上规避。