风中低语:Linux 信号处理的艺术与实践
如果你写过Linux下的服务端程序,大概率经历过这种诡异时刻:进程在线上跑得好好的,没有任何报错日志,突然就没了。检查磁盘、检查内存、检查逻辑代码,什么都查不出来,最后打开core dump一看,发现进程是被某个信号送走的。这种"无声无息"的死亡,恰恰是Linux信号处理最典型的场景——信号就像内核在进程耳边的一声低语,你说没听见吧,它确实来过;你说听见了吧,又往往来不及反应。这篇内容,我打算把Linux信号处理这件事从头到尾讲透,覆盖信号的本质、生命周期、注册方式、阻塞与未决,以及真实项目里的实战姿势和踩坑记录。不管你是刚接触Linux的初学者,还是已经写过不少服务端代码的开发者,这篇文章应该都能帮你在面对signal相关问题时少走一些弯路。
1. 信号到底是什么:内核与进程之间的悄悄话
1.1 先从kill命令说起
很多人第一次认识信号,就是通过kill命令。比如"杀不掉这个进程了,kill -9",这句台词在运维圈几乎天天有人喊。但99%的人可能没想过一个问题:kill为什么能"杀"进程?kill的本质是什么?
实际上,kill只是一个"发信号"的工具,它本身不具备任何杀伤力。它做的事情很简单:调用系统调用kill(2),告诉内核"帮我把某个信号发给某个进程"。至于信号到了之后进程会不会死,取决于两件事:这个信号是什么,以及进程有没有给自己注册对应的处理逻辑。
Linux下的信号,本质上是一个数字编号。这个编号在内核里对应着一张"事件表",每种编号都代表一个特定的事件类型。比如SIGINT这个编号是2,对应的事件是"用户在终端按下了Ctrl+C";SIGTERM的编号是15,对应的事件是"有人请求这个进程优雅地退出";SIGSEGV的编号是11,对应的事件是"进程访问了非法内存地址"。信号到达进程之后,由进程决定怎么处理——当然,不是所有信号都有得选。
1.2 一个更贴近生活的类比
把进程想象成一个大忙人,信号就是发到他手机上的消息。有些消息是提醒式的,忙人可以选择忽略,也可以选择回复;有些消息是"强提醒",一旦收到就必须停下手里的一切去处理;还有一类消息最特殊——发件人是"系统管理员",内容只有两个字:"你被开除了",不管你在干什么、愿不愿意,都会立刻生效。
对应到Linux信号上:
- 大多数信号都可以被进程捕获,也就是注册一个处理函数,在信号到达时执行自定义逻辑;
- 有一小部分信号不可捕获,比如SIGKILL和SIGSTOP,属于内核的强制手段;
- 还有一部分信号如果你不处理,内核会按照默认行为执行,比如"终止进程"或"忽略信号"。
理解这层关系之后,再回头看"kill -9杀进程"这件事就清晰多了:kill -9发的是SIGKILL信号,这个信号直接由内核处理,进程连说"等一下"的机会都没有,结果就是立刻消失。这也是为什么在维护重要服务时,能不用-9就不要用-9的原因——它剥夺了进程清理资源、保存状态的最后机会。
1.3 每个信号背后的故事
Linux标准信号一共有31个(编号1到31),再加上编号32到64的实时信号。作为日常开发,不需要全部背下来,但下面这张表里的信号,几乎每个写Linux程序的人都躲不开:
| 信号 | 编号 | 触发场景 | 默认行为 |
|---|---|---|---|
| SIGHUP | 1 | 终端挂断、进程退出控制终端 | 终止进程 |
| SIGINT | 2 | 用户按下Ctrl+C | 终止进程 |
| SIGQUIT | 3 | 用户按下Ctrl+\ | 终止并生成core dump |
| SIGKILL | 9 | 强制终止命令(kill -9) | 终止进程,不可捕获 |
| SIGSEGV | 11 | 非法内存访问(段错误) | 终止并生成core dump |
| SIGPIPE | 13 | 写入无读端的管道或socket | 终止进程 |
| SIGALRM | 14 | 定时器(alarm/setitimer)到期 | 终止进程 |
| SIGTERM | 15 | 默认的kill信号 | 终止进程 |
| SIGCHLD | 17 | 子进程终止或停止 | 忽略 |
| SIGSTOP | 19 | 停止进程 | 停止进程,不可捕获 |
| SIGCONT | 18 | 继续已停止的进程 | 继续执行 |
| SIGUSR1/2 | 10/12 | 用户自定义事件 | 终止进程 |
观察一下这张表,你会发现一个很有意思的事实:像SIGSEGV、SIGPIPE这种"致命信号",从名字到默认行为都透着一种"事已至此,你活该"的意味;而SIGINT、SIGTERM这种则属于"给你一个体面的机会自己收拾残局"。开发服务端程序时,需要重点处理的通常是SIGINT、SIGTERM、SIGHUP、SIGCHLD、SIGPIPE这几位,后面我会挨个展开讲。
2. 信号的生命周期:从内核投递到进程处理
2.1 信号从哪儿来:三大来源
信号不会凭空出现,它的产生来源主要有三类。
第一类是硬件异常。比如进程执行了非法指令、触发了除零操作、访问了越界地址,CPU在硬件层面检测到问题后,会向操作系统报告,内核据此生成对应的信号(SIGILL、SIGFPE、SIGSEGV等)。这类信号通常意味着程序本身有bug,而且默认行为往往直接让进程崩溃并留下core dump。
第二类是终端按键。当你按下Ctrl+C,终端驱动并不会直接杀进程,它只是把"用户要求中断前台进程"这个信息传递给内核,内核接着向前台进程组的每个进程发送SIGINT信号。类似的,Ctrl+\发送SIGQUIT,Ctrl+Z发送SIGTSTP(挂起进程)。
第三类是软件事件。最典型的例子是定时器到期产生SIGALRM,以及一个进程主动调用kill()、raise()、alarm()、abort()等函数去给另一个进程或自己发信号。UNIX环境编程里引入的一个经典场景就是:父进程用SIGUSR1通知子进程"该继续干活了",子进程用SIGCHLD通知父进程"我已经结束了"。
还有一类比较容易忽略的信号来源是内核本身。比如系统检测到进程写了一个已经关闭的管道——对端已经退出,底层socket已经关闭,数据根本送不出去——这时候内核会补发一个SIGPIPE给你,默认结果就是进程被终止。
2.2 信号的传递路径:内核的"悄悄话"是怎么说的
信号产生之后,并不一定会被进程立刻"听见",它要经过一条比较完整的链路:产生(generation) → 送达(pending) → 投递(delivery)。
当信号送达进程时,内核会给进程的"未决信号表"里打一个标记。如果进程没有阻塞这个信号,它会在一个合适的时机真正进入处理逻辑,这一步叫投递。但如果进程设置了信号屏蔽,那么信号就会一直停留在未决状态,直到进程解除屏蔽。
这里有一个值得深入理解的细节:信号处理动作的发生时机,并不是信号产生的那一刻。内核会选择进程从内核态切换回用户态的这个边界点,检查未决信号表,然后安排处理。换句话说,如果进程正在执行内核代码进行系统调用,那么信号会被挂起,直到系统调用结束、进程准备回到用户态时,才会先跳转到信号处理函数里跑一趟,然后再回到主程序中原本要执行的地址。这也解释了为什么某个耗时很长的系统调用(比如read等待IO),信号的到来会让它提前返回EINTR——因为信号处理函数已经插入进来了,系统调用被打断了。
2.3 不处理信号时,内核替你做主
如果进程没有对某个信号注册处理器,它会按照内核预设的"默认行为"来处理信号。Linux对每个信号的默认行为可以分为五类:
- 终止进程(Term):进程直接被销毁,比如SIGTERM;
- 终止进程并生成core dump(Core):进程终止的同时,把内存现场导出为core文件,方便事后调试,比如SIGSEGV、SIGABRT;
- 忽略信号(Ign):什么都不做,比如SIGCHLD、SIGURG;
- 停止进程(Stop):把进程挂起,不销毁,可以等待继续,比如SIGSTOP、SIGTSTP;
- 继续执行(Cont):让暂停的进程恢复运行,比如SIGCONT。
理解默认行为这一步很重要,很多线上问题其实不是信号处理函数写错了,而是默认行为本身把进程搞死了。举个例子:一个网络服务端的socket连接,如果客户端突然断开,服务端那边再次write()的时候,内核发现已无读端,会立刻发一个SIGPIPE。而SIGPIPE的默认行为是终止进程,于是你的服务端悄无声息地挂了。这种情况实战里太常见了,处理方式我们在后面第五部分专门说。
3. 注册信号处理器:从入门API到正规军
3.1 signal():新手的好朋友,老手的坑
POSIX最初提供的信号注册API是signal(),它的函数签名长得非常劝退:
void (*signal(int signum, void (*handler)(int)))(int);翻译成人话就是:signal()接收一个信号编号和一个函数指针,返回"之前设置的信号处理函数指针"。实际使用的时候不用管这么复杂的声明,直接这么写:
#include <signal.h> #include <stdio.h> void handler(int sig) { // 这里做什么?先卖个关子 } int main(void) { signal(SIGINT, handler); // 主程序逻辑 return 0; }看起来挺简单对吧?但signal()在实战中有一个著名的问题:它在不同UNIX系统上的语义不一致。SysV版本的signal()在信号处理函数执行完毕后,会把该信号的处理方式重置为默认行为,也就是说下一次再收到同一个信号,就会直接走默认流程,你的handler就不生效了。BSD版本的signal()则不会重置,处理完一次之后继续保留你的handler。两种行为各有拥趸,但可移植性极差,所以后来在编写严肃程序时,几乎都统一改用sigaction()。
3.2 sigaction():把控制权牢牢握在手里
sigaction()是POSIX推荐的信号注册方式,它的功能远比signal()丰富,而且行为明确、可移植。
#include <signal.h> struct sigaction { void (*sa_handler)(int); // 信号处理函数 void (*sa_sigaction)(int, siginfo_t *, void *); // 扩展版本,能拿到更多信息 sigset_t sa_mask; // 处理期间额外阻塞的信号集 int sa_flags; // 行为标志位 void (*sa_restorer)(void); // 已废弃,不用管 }; int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);其中sa_flags值得专门记住几个常用取值:
- SA_RESTART:被信号打断的系统调用自动重启(比如慢速读),如果不设置,read/write等调用会返回EINTR错误;
- SA_SIGINFO:表示使用sa_sigaction而不是sa_handler,这样处理器可以获取发送信号的进程PID、信号的具体原因等;
- SA_NOCLDWAIT:配合SIGCHLD使用,子进程结束后内核直接回收,不产生僵尸进程。
我平时写信号处理注册函数时,固定模板是这样一个封装:
#include <signal.h> #include <stdio.h> void setup_signal_handler(int signo, void (*handler)(int)) { struct sigaction act; act.sa_handler = handler; sigemptyset(&act.sa_mask); act.sa_flags = SA_RESTART; // 让慢速系统调用尽量不被打断 if (sigaction(signo, &act, NULL) < 0) { perror("sigaction"); // 真实项目里这里应该记录日志而不是perror } }注意我通常在sa_flags里带上SA_RESTART。这个细节有利有弊:好处是read/write这类系统调用不会因为信号而返回EINTR,省去很多重试代码;坏处是有时候程序设计上就必须依赖EINTR来做超时唤醒,这时候就得去掉SA_RESTART。如果你在维护一段老代码,看到退出的循环条件是errno == EINTR,那多半就是当时设置了不带SA_RESTART的处理器。没有绝对的对错,关键是你得知道自己在做什么。
3.3 处理器里那点"不能做的事"
信号处理函数里有一个非常严肃的约束:只能调用异步信号安全(async-signal-safe)函数。什么意思?就是你只能调用那些即使正在被主程序使用,信号处理器再次调用也不会出问题的函数。
最容易踩的雷区就是printf()。很多初学者在信号处理函数里写printf,一收到信号就疯狂输出,结果程序直接卡死。原因很简单:printf内部有缓冲区加锁机制,如果信号到达的时机恰好是主程序正在执行printf并且已经拿到了缓冲区锁,那么处理器里的printf会去抢同一把锁,从而死锁。malloc、free、fork、sprintf等常见函数同样存在类似风险。
那么处理器里应该做什么?最稳妥的做法是:尽量少做事。实践中推荐使用一种经典模式——处理器只设置一个标志,主循环检查并处理:
#include <signal.h> #include <stdio.h> #include <unistd.h> static volatile sig_atomic_t g_flag = 0; void handler(int sig) { g_flag = 1; // 仅设置标志,安全 } int main(void) { setup_signal_handler(SIGTERM, handler); while (1) { if (g_flag) { // 清理、保存、退出 break; } // 正常工作 } return 0; }volatile的作用是告诉编译器"这个变量的值可能随时被外部改变,不许优化掉",sig_atomic_t保证了对该变量的读写是原子操作,不至于读到中间值。这一对组合是信号处理器里面最安全的通信方式。
4. 信号的阻塞、未决与信号集操作
4.1 阻塞与未决:信号也会排不上队
阻塞(block)和未决(pending)是走到进阶阶段绕不开的两个概念。阻塞可以理解为进程主动对一个信号说"现在别打扰我,晚点再说";而未决就是那个"被挂起、正在等待投递"的信号。
你可能会问:被阻塞的信号,等到解除阻塞的时候,还会来一次吗?答案是:会,但只能来一次。换句话说,如果进程在阻塞期间,对方连发了10个SIGUSR1,解除阻塞后进程只会收到1个SIGUSR1,因为标准信号不排队。这是整个信号处理中一个非常重要的特性,也是很多设计失误的根源。
内核为每个进程维护了两张位图:一张是"信号屏蔽字"(blocked),一张是"未决信号位图"(pending)。产生一个信号时,内核在pending里置位;投递信号前,先检查blocked里是否对应置位,如果被阻塞,信号就一直停留在pending里。注意,一个信号可能被多次产生,但pending里只有一个bit位,最多只能记录"有信号来过",至于来了几次,标准信号是记不住账的。
4.2 信号集操作的完整姿势
先处理信号集本身的初始化,再谈屏蔽和等待。信号集类型是sigset_t,不能直接用=赋值,需要调用专门函数:
#include <signal.h> sigset_t set; sigemptyset(&set); // 清空 sigaddset(&set, SIGINT); // 添加SIGINT sigdelset(&set, SIGINT); // 移除SIGINT int ret = sigismember(&set, SIGINT); // 查询是否包含屏蔽和恢复则靠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是一个"输出参数",用于获取调用前的屏蔽字,方便后面恢复现场。
一个典型的使用场景是:有一段对共享资源读写的临界区不希望被打断,先把SIGINT和SIGTERM全部屏蔽掉,处理完再解除。下面是一段完整可运行的示例:
#include <signal.h> #include <stdio.h> #include <unistd.h> static volatile sig_atomic_t g_flag = 0; void handler(int sig) { g_flag = sig; } int main(void) { setup_signal_handler(SIGINT, handler); sigset_t block_set, old_set; sigemptyset(&block_set); sigaddset(&block_set, SIGINT); // 进入临界区前阻塞 sigprocmask(SIG_BLOCK, &block_set, &old_set); // 模拟临界区操作 printf("critical section start...\n"); sleep(3); // 查询是否有未决信号 sigset_t pending_set; sigpending(&pending_set); if (sigismember(&pending_set, SIGINT)) { printf("SIGINT is pending while blocked\n"); } // 离开临界区,恢复屏蔽字 sigprocmask(SIG_SETMASK, &old_set, NULL); printf("critical section end\n"); // 如果刚才有信号被挂起,这里就会投递 sleep(1); return 0; }这个例子完整展现了"阻塞 → 信号到达成为未决 → 解除阻塞 → 信号投递"的全过程。你可以在终端里运行它,在sleep(3)期间按下Ctrl+C,会发现输出里多了一行pending提示,随后的SIGINT处理器被触发。这就是未决信号与投递时机最直观的体验。
4.3 原子等待:sigsuspend的妙处
如果你需要在等待一个信号的过程中临时解除对其他信号的阻塞,直接先后调用sigprocmask和pause会有一个窗口期:如果两个调用之间信号恰好到达,信号就丢了。
sigsuspend()这个函数专为此而生,它接受一个信号集作为"临时屏蔽字",原子地完成"设置屏蔽字 → 等待信号 → 恢复原屏蔽字"这一整套动作。经典用法是先解除某一信号的阻塞,再挂起等待:
#include <signal.h> #include <stdio.h> static volatile sig_atomic_t g_received = 0; void handler(int sig) { g_received = 1; } int main(void) { setup_signal_handler(SIGUSR1, handler); sigset_t wait_set; sigemptyset(&wait_set); sigaddset(&wait_set, SIGUSR1); printf("waiting for SIGUSR1...\n"); sigsuspend(&wait_set); // 这里set之外被阻塞,SIGUSR1不会被阻塞 // 到了这里,说明SIGUSR1已经被处理过了 if (g_received) { printf("received SIGUSR1\n"); } return 0; }注意这里wait_set的含义与直觉恰好相反:sigsuspend会把进程屏蔽字临时替换为wait_set,所以参数里包含哪个信号、哪个信号反而会被阻塞。正确的逻辑是"想要等到SIGUSR1,就不要把它放进wait_set"。这个反直觉设计坑过不少人,我自己初学的时候也被绕晕过。
5. 真实场景中的信号实战:优雅退出、超时控制与子进程回收
5.1 优雅退出:让服务体面地离开
服务端程序最常见的死法是"被打断就原地去世"。真正稳健的服务,应当能处理SIGINT和SIGTERM,在清理完状态后再退出。下面是一个比较标准的优雅退出框架:
#include <signal.h> #include <stdio.h> #include <unistd.h> #include <stdlib.h> static volatile sig_atomic_t g_stop = 0; static volatile sig_atomic_t g_reload = 0; void handle_signal(int sig) { if (sig == SIGINT || sig == SIGTERM) { g_stop = 1; } else if (sig == SIGHUP) { g_reload = 1; } } int main(void) { setup_signal_handler(SIGINT, handle_signal); setup_signal_handler(SIGTERM, handle_signal); setup_signal_handler(SIGHUP, handle_signal); while (!g_stop) { if (g_reload) { // 重新读取配置文件的逻辑 g_reload = 0; } // 主循环:accept/read/process // 注意使用带超时的IO,避免无限阻塞 sleep(1); } // 善后:关闭fd、同步数据、记录日志 printf("server exited gracefully\n"); return 0; }关键点有两个:一是主循环要用有超时的IO,不能永远卡在read/accept这种不可中断的调用上,否则信号到了也只能等;二是善后逻辑放在主循环之外,不能放在信号处理器里,因为处理器里不允许多做事情。这套模式我用了很多年,在真实生产环境里扛过各种各样的"杀进程"操作,只要不是kill -9,服务都能体面退出。
5.2 超时控制:alarm与setitimer
很多程序都需要"如果XX秒内没反应就当作失败"的超时判断。最原始的做法是alarm(),它会在指定秒数后向进程发送SIGALRM信号,如果不处理,进程就默认终止。这种方式的问题在于分辨率只有1秒,而且一个进程只能有一个alarm定时器。
更高精度的做法是setitimer(),它支持微秒级别,还分三种模式:ITIMER_REAL(真实时间,到点发SIGALRM)、ITIMER_VIRTUAL(进程用户态运行时间,发SIGVTALRM)、ITIMER_PROF(用户态+内核态时间,发SIGPROF)。
#include <sys/time.h> #include <signal.h> void timer_handler(int sig) { // 超时了,做点该做的事 } int main(void) { setup_signal_handler(SIGALRM, timer_handler); struct itimerval t; t.it_interval.tv_sec = 0; // 一次性定时器 t.it_interval.tv_usec = 0; t.it_value.tv_sec = 5; // 5秒后就触发 t.it_value.tv_usec = 0; setitimer(ITIMER_REAL, &t, NULL); // 继续执行可能超时的操作 return 0; }注意一个细节:ITIMER_REAL和alarm()用的是同一个内核定时器设施,所以二者不能混用,后设置的会覆盖先设置的。如果你在一个库里用setitimer实现了超时,又在另一个模块里调用alarm,它们会互相踩。现代高性能编程中很多人转而用timer_create创建基于POSIX时钟的定时器,每个定时器可以独立管理,但那是另外一个话题了。
5.3 SIGPIPE:一路追杀网络服务的隐形杀手
前面提到,往一个对端已关闭的socket连接里写数据,内核会补发SIGPIPE,默认行为就是终止进程。对网络服务端来说这简直是灾难:某个客户端网络波动了一下,连接断了,服务端一写数据,整进程直接崩溃。更糟糕的是,由于不是core dump,可能连日志都没记录到。
处理方案有两种:
第一种,全局忽略SIGPIPE:
signal(SIGPIPE, SIG_IGN);这是最省事的方式。忽略之后,write/read函数会返回-1并设置errno为EPIPE,代码里就能通过这个返回值来识别"连接已关闭"并做清理。
第二种,在使用socket发送时,不依赖信号,而是显式指定MSG_NOSIGNAL标志:
ssize_t ret = send(fd, buf, len, MSG_NOSIGNAL);这样即使连接断开,也不会触发SIGPIPE,而是由send返回EPIPE错误。需要精确控制连接状态的程序更适合用这种方式。我个人推荐"全局忽略SIGPIPE + 代码中专门处理EPIPE"的组合,因为即使是read或write触发的管道破裂,也一并覆盖了,而MSG_NOSIGNAL只对send有效。
5.4 SIGCHLD:子进程的"临终遗言"
父进程fork出来的子进程退出后,内核会给父进程发SIGCHLD信号,目的是提醒父进程"该用wait/waitpid收尸了"。如果父进程一直不回收,子进程就会变成僵尸进程,占据进程表项,长时间积累能把系统的进程数上限耗尽。
忽略SIGCHLD是一个省力的办法:如果父进程set了SIGCHLD的handler为SIG_IGN,内核会在子进程退出时直接回收,连僵尸状态都不会产生。但这会带来另一个问题:你也获取不到子进程的退出状态了。很多严肃的项目还是选择注册SIGCHLD处理器,在处理器中用waitpid循环回收:
#include <signal.h> #include <sys/wait.h> #include <stdio.h> void chld_handler(int sig) { int status; pid_t pid; // 用WNOHANG循环:一次wait只能回收一个子进程, // 如果同时多个子进程退出,必须循环直到返回0 while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { printf("child %d reaped\n", pid); } }这里有一个很关键的实战细节:必须循环调用waitpid,直到返回0或-1,而不是只调用一次。因为标准信号不排队,如果多个子进程几乎同时退出,内核只投递一次SIGCHLD,你如果只wait一次,剩下的子进程就会变成僵尸。这个坑我踩过不止一次,排查僵尸进程时一度怀疑是代码逻辑问题,最后发现就是"只收了一次"。
还有一个进阶技巧:如果你的服务完全不关心子进程的退出码,可以用sigaction配合SA_NOCLDWAIT标志。设置了SA_NOCLDWAIT之后,子进程退出时不会产生僵尸,内核自动回收,父进程也不需要SIGCHLD处理器,一举两得。但同样地,你也失去了获取子进程状态的能力,需要根据业务权衡。
6. 那些年我们踩过的信号坑:排查链路的完整复盘
6.1 信号处理器调用printf导致死锁:一次真实的卡死事故
有一年我接手一个消息系统,线上偶现进程完全卡死的现象,没有任何报错。用gdb attach上去,所有线程的堆栈都停在printf相关的锁上,主线程停在正常的日志输出里,而一个信号处理线程停在同一个printf上。两把锁互相等待,直接死锁。
复盘时发现,信号处理器里写了printf用于调试,上线时没删干净。平时不触发信号也就没事,一触发就碰到主线程正在打印日志的窗口,双方抢同一把锁,进程就"两败俱伤"了。修复方案很简单:把调试printf全删了,处理器里只保留标志位的赋值。但这类事故特别容易在"赶进度、先调试后补"的场景下被带进生产环境,所以我的建议是:在代码评审里把"信号处理器里是否包含非异步安全函数"作为一条硬性检查项。
6.2 竞态:检查标志与等待信号之间丢了信号
另一个常见问题是"出发前看了一眼,结果信号偏偏在那一瞬间到了"。典型的错误写法是:
while (1) { if (g_flag) break; pause(); // 永久等待信号 }问题出在if检查g_flag和pause()之间有一个窗口期:如果信号在检查完之后、pause()之前到达,信号处理完了,g_flag置位,但进程还在pause()里睡着,不会醒来,一直挂到天荒地老。虽然这个例子稍微有点刻意,但实际业务中"先检查条件、再等待事件"这种模式,真的很常见。
解决方案是用sigsuspend这种原子等待,或者把"条件检查"和"等待"一并放进一个原子操作里,确保没有窗口期。本质上,信号处理竞态和并发编程里的check-then-act竞态是一回事,只不过并发是多个线程,这里是信号打断主流程。
6.3 多线程程序里的信号:到底发给谁了
很多人把单进程模型里的信号处理经验直接搬进多线程,结果发现行为诡异。原因在于多线程程序的标准信号默认发送给任意一个不阻塞该信号的线程,而不是固定发给主线程。如果某个线程恰好屏蔽了这个信号,另一个线程收到了,处理函数执行的上下文就是不确定的。
正确的多线程信号处理方法有两种常用套路:
第一种,让所有线程都阻塞信号,单独用一个线程调用sigwait/sigtimedwait专门取信号。这样信号到达后不是打断某个线程,而是"排队"给信号线程处理,彻底避免了异步打断带来的所有麻烦:
#include <signal.h> #include <pthread.h> void *signal_thread(void *arg) { sigset_t set; sigemptyset(&set); sigaddset(&set, SIGINT); sigaddset(&set, SIGTERM); // 这个线程只负责等待和取出信号 int sig; while (1) { sigwait(&set, &sig); printf("received signal %d\n", sig); // 统一处理 } return NULL; }第二种,仍然使用sigaction,但在每个相关线程的起始处统一调用pthread_sigmask设置屏蔽字,将信号定向投递到指定线程。这种方式控制粒度更细,但复杂度也更高。
我个人的建议是:新项目直接走sigwait专线程方案,就算将来要加信号,也多只需要扩展一下switch-case,比在多个线程里分散处理信号要省心得多。
6.4 kill(0)误伤进程组:一炮打翻一船人
还有一个特别容易忽略的细节:kill()函数的第一个参数如果是0,含义是"给当前进程组的所有进程发送信号",而不是给PID为0的进程。在包含多个子进程的服务里,如果你误调用了kill(0, SIGTERM),等于向整个进程组的所有进程发出终止信号,所有兄弟进程、后台进程全部阵亡。这个bug极其隐蔽,因为错了也不会报错。
判断是不是进程组误伤,可以在出问题时用ps -eo pid,pgid,comm查看哪些进程处于同一个PGID。真正想给指定进程发信号,务必确认pid参数是正数,而且要检查返回值,kill失败会返回-1并设置errno。
6.5 与exec的相爱相杀
如果一个进程在设置了信号处理器之后执行exec族函数(比如fork后立即exec),那么exec会把所有已设置为"捕获"的信号恢复为默认行为,但继承了被忽略信号的设置保持不变。这个行为是POSIX规定的。
这个规则带来一个经典问题:如果子进程在exec之前收到了"忽略SIGPIPE"的设置,exec之后SIGPIPE依然是忽略状态;但如果子进程在exec之前注册过自定义handler,exec之后handler的信息就丢了。这意味着,如果你想在某个外部程序执行期间屏蔽某些信号,不能依赖父进程注册的处理器,而要在exec之前用signal(SIGXXX, SIG_IGN)显式忽略,因为忽略状态是跨exec继承的。
写在最后的一点体会
信号处理这个东西,写起来好像就是几个API的事,但真正用好的关键在于对"异步打断"这件事的敬畏。内核在任意一个指令边界都可能把控制权交到你的信号处理器手里,而你的主程序对此一无所知。正因为这种打断是不可预期的,信号处理器里才要求极度克制、只做最低限度的操作,把真正的业务逻辑留在主循环里执行。
我做Linux下的后台服务也有不少年头了,回过头看,那些线上最诡异的进程消失、卡死、僵尸堆积的问题,几乎都能归结到本文讲过的几个点上:要么是默认行为没搞清楚,要么是处理器里干了不该干的事,要么是忘记了标准信号不排队。把这几个底层逻辑刻在脑子里,再遇到类似的问题,排查方向基本不会跑偏。
最后分享一个我从老前辈那儿学来的习惯:每次写完信号处理相关代码,都问自己一遍"如果这个信号在每一行代码执行完之后都能到来,我的代码还安全吗"。想清楚了这个问题,你就真正掌握了信号处理的艺术。