先从一个我实际踩过的坑说起。写网络服务程序时,主线程阻塞在read()上等数据,准备等SIGINT到来优雅退出。结果信号一来,read()直接返回 -1,errno变成了EINTR,我还以为网络出故障了。后来又遇到更隐蔽的:程序加了-O2优化编译,信号处理函数里改了标志位,主循环却像被焊死一样永远跳不出去。找来找去,最后定位到volatile关键字头上。
说实话,Linux 进程信号这套东西,光看概念不难,难的是把「信号保存、处理、捕捉」整个链路串起来后,再去理解那些和它纠缠在一起的知识点——可重入函数、volatile、SIGCHLD。这些知识点单独看一篇文章都懂,但组合在一起,就特别容易在实战中翻车。这篇文章我会按我自己的理解,把这条链路从信号产生一直讲到信号处理函数返回,顺带把可重入和编译器优化这两个深水区也捞一遍,最后聊聊用SIGCHLD回收僵尸进程的正确姿势。
1. 信号的一生:从产生到递达,中间隔着"保存"这道坎
1.1 为什么信号不是"随叫随到"的
很多初学者有个误解:信号产生后就会立刻执行处理函数。实际上不是这样。信号从产生到处理,中间要经过两个状态:
- 未决状态(Pending):信号已经产生,但还没有被进程处理。
- 递达状态(Delivered):信号真正被进程处理,执行默认动作或自定义动作。
那什么情况下信号会停在"未决"状态不往前走?答案是被阻塞。每个进程的 PCB 里都维护着两张位图:一个是阻塞信号集(也叫信号屏蔽字,block set),一个是未决信号集(pending set)。内核在准备递达信号之前,会先看这个信号在不在阻塞集里,如果在,信号就只能先放在未决集里"挂号排队",直到阻塞被解除。
这里要注意一个关键细节:信号不是软硬件里的中断,它没有自己的优先级抢占机制。进程正在执行用户态代码时,信号到来并不会马上打断它,要等到进程从内核态返回用户态的那一刻,内核才会检查有没有要递达的信号。这一点在后面的"捕捉"部分还会再提。
1.2 内核怎么"保存"信号:两个位图才是真相
阻塞集和未决集的本质都是sigset_t类型的位图结构,每个信号占一个 bit。比如SIGINT是 2 号信号,那就看位图的第 2 位。内核在处理信号递达时,会执行这样一个简单判断:
如果 信号 不在 阻塞集 中: 从 未决集 清除该信号 → 执行递达 否则: 保留在 未决集 中 → 等待这也就解释了另一个经典现象:如果一个信号在被阻塞期间多次产生,未决集里始终只置一个 bit,也就是"合并"成一次。常规信号不排队,丢失是正常的,不是内核 bug。实时信号 (RT signals) 才带排队机制。
我们完全可以自己动手模拟"信号保存"的完整过程。用sigprocmask()屏蔽掉SIGINT,然后按下 Ctrl+C,再调用sigpending()查看未决集:
#include <stdio.h> #include <signal.h> #include <unistd.h> int main() { sigset_t block, oldset; sigemptyset(&block); sigaddset(&block, SIGINT); // 屏蔽 SIGINT sigprocmask(SIG_BLOCK, &block, &oldset); printf("SIGINT 已被屏蔽,请在5秒内按下 Ctrl+C...\n"); sleep(5); sigset_t pending; sigpending(&pending); if (sigismember(&pending, SIGINT)) { printf("检测到:SIGINT 正处于未决状态(被保存了)\n"); } // 解除屏蔽,刚才的 Ctrl+C 会在这时候真正递达 sigprocmask(SIG_SETMASK, &oldset, NULL); printf("屏蔽解除,SIGINT 现在递达\n"); return 0; }这个例子把"保存"这件事表现得非常直观。实测跑一下就能看到,信号在处理前是实打实被"存"在 PCB 里的。阻塞解除的瞬间,进程再回到用户态时,内核发现未决位图上有 SIGINT,才真正递达,你才会看到进程被终止。
1.3 为什么要区分"同步信号"和"异步信号"
信号来源分两类,理解这一点对排查问题很有帮助。
- 同步信号:进程自己执行指令时产生的,比如除零触发
SIGFPE、非法内存访问触发SIGSEGV。这类信号是"当场产生、当场未决、立刻递达",基本上没有机会屏蔽。 - 异步信号:由其他进程通过
kill()系统调用,或者内核因为外部事件(如终端 Ctrl+C)发过来的。这类信号才是我们平时讨论的"保存、阻塞、捕捉"的主要对象。
我们写业务代码时接触的大部分是异步信号。同步信号更多出现在崩溃分析里——core dump就是同步信号的结果。
2. 三种宿命:默认动作、忽略处理、自定义捕捉
2.1 一张表看懂系统提供的默认动作
信号产生并递达后,进程不外乎几种处理方式:终止进程、终止并产生 core dump、暂停(Stop)、继续运行(Continue)、忽略。Linux 下每种信号都有默认动作,很多信号的默认动作是终止进程。以下是我总结的常用信号默认行为:
| 信号 | 编号 | 默认动作 | 常见触发场景 |
|---|---|---|---|
| SIGINT | 2 | 终止进程 | Ctrl+C |
| SIGQUIT | 3 | 终止 + core dump | Ctrl+\ |
| SIGKILL | 9 | 终止进程 | kill -9 |
| SIGSEGV | 11 | 终止 + core dump | 非法内存访问 |
| SIGCHLD | 17 | 忽略 | 子进程停止或退出 |
| SIGSTOP | 19 | 暂停进程 | Ctrl+Z 的底层机制之一 |
| SIGCONT | 18 | 继续运行 | 让暂停的进程继续 |
特别注意SIGKILL和SIGSTOP这两个信号:既不能被捕捉,也不能被忽略,更不能被阻塞。这是内核的硬性规定,因为系统必须保留一种"最后的强制手段"来管理失控进程。写程序时如果收到kill -9没反应,先想想到底是什么还挂在那里拖住了进程,而不是怀疑"信号被吞了"。
2.2 自定义捕捉为什么优先用 sigaction 而不是 signal
自定义捕捉就是把信号处理和自己的函数绑定起来。Linux 提供了两套接口:老牌的signal()和 POSIX 标准的sigaction()。
我在实际开发中很少用signal(),原因很直接:它在不同 Unix 系系统上的语义不一致。在 System V 风格的实现里,signal()注册的处理函数在执行一次之后会被重置回默认动作,也就是说第二次信号到来时行为会变。这在一个长时间运行的服务进程里是灾难性的。虽然 Linux 的 glibc 实现行为接近 BSD 语义,不会自动重置,但为了可移植性和可控性,我统一用sigaction()。
看看sigaction()的标准用法:
#include <signal.h> #include <stdio.h> #include <unistd.h> static void handler(int sig) { // 注意:这里只做了最保守的事情——写一个全局标志位 write(STDOUT_FILENO, "signal caught\n", 14); } int main() { struct sigaction act; act.sa_handler = handler; sigemptyset(&act.sa_mask); // 处理期间不再额外屏蔽其他信号 act.sa_flags = 0; // 先不设置 SA_RESTART,稍后解释 sigaction(SIGINT, &act, NULL); while (1) { sleep(1); } return 0; }struct sigaction里最容易被忽略的字段是sa_mask。它表示的是:当你的处理函数正在执行时,哪些信号应该被额外阻塞。这可以解决一个经典的递归问题:处理SIGINT的过程中又来了SIGINT,如果没有在sa_mask里阻塞它,就会再次调用处理函数,极端情况下导致栈溢出。把act.sa_mask加上SIGINT本身,能保证函数在执行期间不会被同一个信号反复打断。
3. 信号捕捉的内核之旅:为什么处理函数"执行一半还能被再触发"
3.1 从用户态到内核态的两次往返
理解信号捕捉的关键在于一个此前没有展开的细节:信号处理函数不是在信号产生的瞬间被调用的,而是在进程被内核"翻牌子"的那个特定时点。
完整流程是这样的:
- 进程正在用户态执行
main里的代码。 - 某个信号产生,内核将对应位置位。
- 进程因为系统调用、中断、异常等原因陷入内核态。
- 内核处理完事务,准备返回用户态时,检查当前进程的未决信号。
- 发现信号未屏蔽、且注册了自定义处理函数,于是不回原来的用户态指令位置,而是跳到处理函数入口执行。
- 处理函数执行完,调用特殊的
sigreturn系统调用再次进入内核。 - 内核恢复之前被打断的用户态上下文,回到原来的断点,程序继续往下跑。
从第 5 步到第 7 步,处理函数是在用户态执行的,但"怎么跳过去、怎么跳回来"是靠内核完成的两轮切换。这也是为什么信号处理函数不能随便调用longjmp之类的操作——它的返回路径不是普通的函数返回,而是依赖sigreturn特殊机制。
补充一个高频考点的底层原因:为什么信号处理函数执行期间,同类信号"再来一次也不会无限递归"?因为从进入处理函数那一刻开始,内核会自动把正在处理的这个信号加入阻塞集,处理完毕后再恢复。这层保护是内核提供的,不依赖你写不写sa_mask。
3.2 慢系统调用和 EINTR:被信号打断的 read 到底错在哪
回到文章开头我踩的第一个坑。read()在阻塞等待网络数据时收到信号,内核跳到信号处理函数;处理函数返回后,read()并不会自动恢复阻塞等待,而是直接返回 -1,errno被设为EINTR。这是很多服务端程序第一次接触信号时最困惑的地方:没出错,却被系统"强行中断"了。
解决办法有两个方向:
- 在
sigaction中设置sa_flags = SA_RESTART,让内核在处理完信号后自动重启被打断的系统调用。 - 不设置
SA_RESTART,自己判断errno == EINTR后重试。
ssize_t n = read(fd, buf, sizeof(buf)); if (n < 0 && errno == EINTR) { // 信号打断,重试即可 n = read(fd, buf, sizeof(buf)); }我的经验是:不要无脑依赖SA_RESTART。有些系统调用(比如select、poll、epoll_wait)即使设置了SA_RESTART也可能不会重启,行为因系统而异。更稳妥的做法是捕获EINTR并循环重试。
4. 与信号掰手腕的两大门派:可重入函数和 volatile
4.1 可重入函数到底是什么,和线程安全有什么区别
信号处理函数本质上是在主程序的执行流里"插入"的一段代码。这就产生了一个必须面对的问题:主程序刚执行到一半,处理函数插进来,如果两者用了同一份共享数据,会不会乱套?
这就引出了可重入函数的概念。所谓可重入,是指一个函数可以被中断后再次进入,而不会破坏内部数据。重点在"中断"——这是和线程安全最大的区别:线程安全函数防的是"两个线程同时执行",可重入函数防的是"同一条执行流被信号打断后又执行一遍"。
最经典的不可重入例子是strtok()。它内部用一个静态指针记录当前分割位置,如果主程序正在解析字符串 A,信号来了,处理函数里也调用了strtok()去解析字符串 B,等处理函数返回,主程序再继续调用strtok()时,静态指针已经被改到 B 的位置了,字符串 A 的解析就会错乱。
再看malloc()。现代 glibc 的实现里malloc是线程安全的,但它内部会有全局锁或内存状态管理。如果主程序正在malloc的过程中被信号打断,处理函数里又调用malloc,就可能出现锁状态不一致甚至死锁——这就是为什么 POSIX 明确规定了处理函数里尽量只调用async-signal-safe(异步信号安全)函数。
我整理了一张常用的对比表,用man 7 signal-safety可以查到完整清单:
| 函数类型 | 典型代表 | 能否在信号处理函数中直接调用 |
|---|---|---|
| 系统调用 | read、write、open、waitpid | 安全(async-signal-safe) |
| 简单库函数 | getpid、sigismember、sigprocmask | 安全 |
| 内存管理 | malloc、free、realloc | 不安全,不要调用 |
| 标准 I/O | printf、fprintf、puts | 不安全 |
| 字符串解析 | strtok、rand、srand | 不安全 |
所以我在写信号处理函数时,基本只做一件事:设置一个全局标志位,或者给管道写一个字节,真正的业务处理全部放到主循环里。不要在handler里做任何复杂操作,这是血的教训换来的经验。
4.2 volatile:与编译器优化的一场持久战
如果说可重入函数坑的是"运行时逻辑",那volatile坑的就是"编译期优化"。这个问题在-O2以下几乎不会暴露,一开优化必现。
看这段经典的"死循环"代码:
#include <signal.h> #include <stdio.h> int flag = 0; // 注意:没有 volatile void handler(int sig) { flag = 1; } int main() { signal(SIGINT, handler); printf("Waiting...\n"); while (!flag) { // 空转 } printf("Flag set, exit\n"); return 0; }我用-O2编译这个程序后,按下 Ctrl+C,信号处理函数明明把flag改成了 1,但主循环还是永远跳不出去。当时我盯着 GDB 的汇编代码看了半天,才明白问题出在哪:
- 编译器优化后发现,
while (!flag)这个循环体的每一次迭代都没有修改flag。 - 于是它自作聪明地做了一次优化:
flag的值被加载进 CPU 寄存器,之后每次循环都直接读寄存器,不再访问内存。 - 信号处理函数里修改
flag,改的是内存里的值,寄存器里的副本纹丝不动,主循环自然感知不到。
这个问题的本质是:信号处理函数的执行,是编译器在编译时根本无法预见的运行时事件。编译器只能在它看得见的代码范围内优化,但它看不见"外部强改"的情况。
解决办法就是加volatile关键字:
volatile sig_atomic_t flag = 0;volatile告诉编译器:这个变量的值可能被不可预知的方式改变,不要把它优化进寄存器,每次使用都老老实实从内存读取。
这里还要提醒一个高频误区:volatile不是万能并发工具,它解决不了多线程数据竞争问题。但在信号处理这个场景下,它语义上是正确的——因为我们面对的是"单线程 + 异步信号",不存在多线程同时写的问题,只存在"编译器优化导致内存不可见"的问题。
另一个细节是建议用sig_atomic_t类型存储这种标志位。这是 C 标准专门为信号处理场景定义的类型,它保证单次读写是原子的,不会出现读到一半被信号打断、读到半个值的情况。
5. SIGCHLD:一个被大多数初学者忽略的"异步通知神器"
5.1 僵尸进程为什么必须由父进程亲手解决
先明确一个事实:子进程退出后,不会立刻从系统里消失,而是会留下一个最小的残留——进程表项至少保留 PID、退出状态、占用资源统计信息,等待父进程来"收尸"。这个状态就是僵尸进程(zombie)。
僵尸进程无法用kill杀掉,因为它的生命已经结束了。唯一的办法是让父进程调用wait()或waitpid(),把这些信息取走,系统才能彻底释放进程表项。出错的是,很多父进程根本不关心子进程的退出状态,也不想阻塞在waitpid()上等子进程结束,于是僵尸进程就积累了下来。
如果只是几个僵尸进程还好说,如果父进程是长期运行的服务进程,每来一个请求就 fork 一个子进程,然后又不管,僵尸进程就会像滚雪球一样堆积,直到把进程表挤爆,新进程 fork 不出来。
5.2 用 SIGCHLD 实现"自动收尸":为什么处理函数里要写 while 循环
Linux 内核在子进程状态变化的时候,会主动给父进程发SIGCHLD信号。这等于内核替父进程装了一个"婴儿监护器":孩子退出了,马上通知你。默认动作是忽略,但我们完全可以在自定义处理函数里调用waitpid()把子进程回收掉。
一个标准的收尸处理函数长这样:
void child_handler(int sig) { int status; pid_t pid; // 这里必须用 while 而不是 if while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { // 成功回收一个子进程 } }为什么要写while?这是SIGCHLD相关面试题里最常踩的坑。
常规信号不排队。如果 5 个子进程在极短的时间内同时退出,内核可能会发出多次SIGCHLD,但这些信号在未决阶段就被合并了,父进程最终可能只看到一次。如果在处理函数里只waitpid一次,那就只回收了一个子进程,剩下四个继续当僵尸。
while循环配合WNOHANG的意图很明确:
waitpid(-1, ...)表示回收任意一个子进程。WNOHANG让调用立即返回,不会阻塞在信号处理函数里。- 循环一直收,直到返回 0(没有更多退出的子进程)或 -1(没有子进程了),把所有"积压"的僵尸一次性扫干净。
还有一个和SIGCHLD相关的选项:在sigaction里设置sa_flags = SA_NOCLDWAIT。设置后,子进程一旦退出,内核直接自动回收,不产生僵尸状态,父进程也不再需要调用waitpid来收尸。这招在某些"完全不关心状态"的服务场景里能省不少事,但代价是拿不到子进程的退出状态,排查问题会少很多线索。
我个人建议:普通场景别用SA_NOCLDWAIT,老老实实while + waitpid,多出来的是几行代码,省下的却是排查问题时的一双眼睛。
5.3 SIGCHLD 处理中的"信号安全"纪律
最后强调一个实操纪律:waitpid属于 async-signal-safe 函数,在信号处理函数里调用是安全的,这一点请放心。但不代表可以在那个函数里乱来,以下是我在项目里反复对自己强调的三条铁律:
- 不要在信号处理函数里调
printf打日志。printf涉及内部缓冲区和锁,不是异步信号安全函数,调试时偶尔用write代替。 - 不要在主程序里使用
signal()注册 SIGCHLD,统一用sigaction,方便设置sa_flags。 - 如果主程序自己也要关注子进程,记得处理
EINTR:waitpid被嵌套信号打断时也可能返回 -1。
这三条单独看都像是"小心驶得万年船",但真在这上面栽过跟头的同学,大概能体会到它们的分量。我最初就是因为贪方便,在SIGCHLD处理函数里加了几个printf,结果程序在压力测试下偶发卡死,折腾了两天才定位到是缓冲区和锁的问题。
Linux 进程信号的这套知识,表面上零散,实际每个点都是连着的:不理解"保存"环节,就理解不了为什么信号会丢失;不理解"捕捉"的内核往返机制,就理解不了EINTR和sa_mask的意义;不处理可重入函数和编译器优化的问题,信号处理函数本身就是个定时炸弹。把这些链条走通之后,再回头看kill、SIGCHLD这些具体场景,基本就是顺着同一个思路往下一路平推了。
写到这里,我把标题里提到的几个知识点全串完了一遍。最后分享一个我自己的实操习惯:所有注册信号的代码,一律用sigaction而不是signal;所有处理函数,函数体不超过十行,最多置标志位、向管道写一字节;所有需要循环回收的场景,第一反应就是while (waitpid(...))。这套习惯帮我避开了很多莫名其妙的线上故障,也希望能给正在啃这块知识的你一个清晰的落点。