ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux信号处理全链路解析:从保存到EINTR、volatile与SIGCHLD实战

Linux信号处理全链路解析:从保存到EINTR、volatile与SIGCHLD实战 先从一个我实际踩过的坑说起。写网络服务程序时主线程阻塞在read()上等数据准备等SIGINT到来优雅退出。结果信号一来read()直接返回 -1errno变成了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然后按下 CtrlC再调用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秒内按下 CtrlC...\n); sleep(5); sigset_t pending; sigpending(pending); if (sigismember(pending, SIGINT)) { printf(检测到SIGINT 正处于未决状态被保存了\n); } // 解除屏蔽刚才的 CtrlC 会在这时候真正递达 sigprocmask(SIG_SETMASK, oldset, NULL); printf(屏蔽解除SIGINT 现在递达\n); return 0; }这个例子把保存这件事表现得非常直观。实测跑一下就能看到信号在处理前是实打实被存在 PCB 里的。阻塞解除的瞬间进程再回到用户态时内核发现未决位图上有 SIGINT才真正递达你才会看到进程被终止。1.3 为什么要区分同步信号和异步信号信号来源分两类理解这一点对排查问题很有帮助。同步信号进程自己执行指令时产生的比如除零触发SIGFPE、非法内存访问触发SIGSEGV。这类信号是当场产生、当场未决、立刻递达基本上没有机会屏蔽。异步信号由其他进程通过kill()系统调用或者内核因为外部事件如终端 CtrlC发过来的。这类信号才是我们平时讨论的保存、阻塞、捕捉的主要对象。我们写业务代码时接触的大部分是异步信号。同步信号更多出现在崩溃分析里——core dump就是同步信号的结果。2. 三种宿命默认动作、忽略处理、自定义捕捉2.1 一张表看懂系统提供的默认动作信号产生并递达后进程不外乎几种处理方式终止进程、终止并产生 core dump、暂停Stop、继续运行Continue、忽略。Linux 下每种信号都有默认动作很多信号的默认动作是终止进程。以下是我总结的常用信号默认行为信号编号默认动作常见触发场景SIGINT2终止进程CtrlCSIGQUIT3终止 core dumpCtrl\SIGKILL9终止进程kill -9SIGSEGV11终止 core dump非法内存访问SIGCHLD17忽略子进程停止或退出SIGSTOP19暂停进程CtrlZ 的底层机制之一SIGCONT18继续运行让暂停的进程继续特别注意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()并不会自动恢复阻塞等待而是直接返回 -1errno被设为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. 与信号掰手腕的两大门派可重入函数和 volatile4.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/Oprintf、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编译这个程序后按下 CtrlC信号处理函数明明把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。如果主程序自己也要关注子进程记得处理EINTRwaitpid被嵌套信号打断时也可能返回 -1。这三条单独看都像是小心驶得万年船但真在这上面栽过跟头的同学大概能体会到它们的分量。我最初就是因为贪方便在SIGCHLD处理函数里加了几个printf结果程序在压力测试下偶发卡死折腾了两天才定位到是缓冲区和锁的问题。Linux 进程信号的这套知识表面上零散实际每个点都是连着的不理解保存环节就理解不了为什么信号会丢失不理解捕捉的内核往返机制就理解不了EINTR和sa_mask的意义不处理可重入函数和编译器优化的问题信号处理函数本身就是个定时炸弹。把这些链条走通之后再回头看kill、SIGCHLD这些具体场景基本就是顺着同一个思路往下一路平推了。写到这里我把标题里提到的几个知识点全串完了一遍。最后分享一个我自己的实操习惯所有注册信号的代码一律用sigaction而不是signal所有处理函数函数体不超过十行最多置标志位、向管道写一字节所有需要循环回收的场景第一反应就是while (waitpid(...))。这套习惯帮我避开了很多莫名其妙的线上故障也希望能给正在啃这块知识的你一个清晰的落点。
返回列表