十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

深入理解Linux信号保存:阻塞集与未决集如何决定进程行为

深入理解Linux信号保存:阻塞集与未决集如何决定进程行为 如果你写过Linux下的服务程序八成有被信号坑过的经历进程莫名其妙退出了CtrlC 失灵子进程退出了却没等到 SIGCHLD或者反复 kill 一个进程它怎么都不死。信号这个东西表面上只是“发个通知”但真正决定你程序行为的地方恰恰藏在内核保存信号的那一套机制里。这里说的信号保存不是把信号处理函数存起来而是指信号从产生到最终执行 handler 之间内核在进程的 PCB 上先“记一笔账”的过程——这笔账记在哪、怎么记、记多久直接决定了你能否收到这个信号、收到几次、以及什么时候才会收到。这篇文章我想把这个过程完整拆开从进程控制块里的两个位图讲起再把阻塞、未决、递达、恢复这些概念用代码串起来。不管你是写系统层应用、做嵌入式开发还是在准备 Linux 运维或后端面试把“信号保存”这一整条链路吃透很多疑难杂症都会自动变得明朗。下面全部基于 Linux 内核的实际行为来聊我用一套可复现的 C 代码带你亲手“观察”信号的保存过程顺便把我踩过的坑也一并交底。1. 先搞懂“信号保存”到底保存的是什么1.1 信号不是消息队列而是一笔“待办账”很多人对信号最大的误解是把它当成一种类似消息队列的机制以为发多少个信号进程就会依次接收多少个。真实情况完全不是这样。信号本质上是一个异步事件通知它不是一个携带大量数据的消息包而是内核在目标进程内部设置的一个状态标记。当我们调用kill(pid, sig)或者用户在终端按下 CtrlC又或者进程自身触发了段错误、浮点异常时内核会走一条大致相同的路径找到目标进程的task_struct然后把信号“登记”到这个进程的信号状态里。这个登记动作就是信号保存的核心。Linux 里每个进程的信号状态主要由两部分组成未决信号集pending set记录“已经产生、但还没被进程处理”的信号。阻塞信号集blocked set也叫 signal mask记录“当前被进程屏蔽、暂时不想处理”的信号它决定了哪些信号即使产生了也不能递达。你可以在脑海里想象成一个快递柜每个信号编号对应一个格子。快递到了信号产生如果收件人暂时无法取件进程忙/信号被阻塞快递员就会往格子里塞一张取件通知单写入 pending 位图。等到收件人有空了、同意接收了再凭通知单去取件执行 handler。信号保存保存的就是这张“取件通知单”。注意信号保存的“存”动作发生在内核态普通进程无法直接读取或修改另一个进程的 pending 集合只能通过sigpending()查询自己或其线程组的未决信号通过sigprocmask()修改自己的阻塞集。从数据结构上说task_struct里有一个sigpending结构里面既有一个sigset_t类型的位图也有一个实时信号链表。标准信号靠位图记录位图里每个 bit 对应一个信号编号实时信号靠链表排队同时还能携带附加数据。这部分细节我会在下面展开。1.2 标准信号和实时信号保存的精度完全不同Linux 的经典信号1 到 31和 POSIX 实时信号SIGRTMIN 到 SIGRTMAX在保存机制上有本质差异这也是很多线上事故的根源。先说标准信号。由于内核用一个 bit来表示“某个信号是否未决”所以同一个标准信号如果连续产生多次在尚未递达之前只会被记录为“存在”不会记录“来了几次”。比如进程阻塞了 SIGINT然后你连续向它发三次kill -INT最终解除阻塞后进程只会收到一次 SIGINT。这不是 bug这是设计使然——标准信号不排队。再说实时信号。实时信号在 pending 链表里是逐个排队的发三次就能收到三次而且每个信号还可以携带一个整数或指针值通过sigqueue()发送。所以才有了那句经验之谈如果你需要用信号做精确计数或传递数据不要用标准信号要么改用实时信号要么改用共享内存 / eventfd / 管道。我把两者的区别整理成了下面这张对比表方便你记忆对比项标准信号1~31实时信号SIGRTMIN~SIGRTMAX保存方式pending 位图中一个 bitpending 链表中一个节点是否排队不排队多次发送合并为一次排队发送几次处理几次是否携带数据不能携带数据可通过 sigqueue 携带整数或指针处理顺序编号小者优先编号相同的无先后发送顺序即处理顺序适用场景通知类事件、进程控制需要计数、需要传参、需要严格有序的场景我第一次意识到这个区别是在一个网络代理程序上。当时我用 SIGUSR1 通知工作进程刷新配置结果连续刷了三次配置进程只刷新了一次查了很久才发现是标准信号合并造成的。后来改成sigqueue()发实时信号问题立刻消失。从那以后我但凡看到有人用标准信号“数次数”都会提醒一句标准信号最重要的语义是“有无”不是“多少”。2. 两个位图的博弈阻塞集与未决集如何配合工作2.1 从“产生”到“递达”未决信号到底在等什么一个信号从产生到它的处理函数真正被执行中间会经历几个状态我习惯用一条链路来记产生generation→ 注册/保存到未决集pending→ 递达delivery→ 处理handler如果信号在递达之前被进程阻塞了它并不会消失而是会一直“挂在”未决集里直到阻塞被解除。解除阻塞的一瞬间内核会检查未决集中有哪些信号现在可以递达然后立即执行对应的处理动作默认动作、忽略、或自定义 handler。这里有一个容易混淆的点阻塞和忽略不是一回事。忽略SIG_IGN是信号到达后不做任何处理阻塞是信号到达后先存起来等不阻塞了再处理。区别用代码最容易说明白。我写了一段演示程序它把 SIGINTCtrlC 产生的信号加入阻塞集然后睡 5 秒。这 5 秒里你随便按 CtrlC或者从另一个终端kill -INT 进程PID信号都会被内核保存到未决集合。5 秒后程序先调用sigpending()打印未决信号再解除阻塞你会看到 SIGINT 瞬间被处理掉。#include stdio.h #include signal.h #include unistd.h #include string.h void handler(int sig) { write(STDOUT_FILENO, got SIGINT\n, 11); } int main(void) { sigset_t set, pending; struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler handler; sigemptyset(sa.sa_mask); sa.sa_flags 0; sigaction(SIGINT, sa, NULL); sigemptyset(set); sigaddset(set, SIGINT); sigprocmask(SIG_BLOCK, set, NULL); printf(SIGINT blocked, pid%d, send SIGINT now...\n, getpid()); sleep(5); sigpending(pending); if (sigismember(pending, SIGINT)) printf(SIGINT is pending now\n); printf(unblocking SIGINT\n); sigprocmask(SIG_UNBLOCK, set, NULL); sleep(1); return 0; }编译运行一下$ gcc -o sigtest sigtest.c $ ./sigtest SIGINT blocked, pid12345, send SIGINT now...在另外的终端执行$ kill -INT 12345回到原终端你会看到类似输出SIGINT is pending now unblocking SIGINT got SIGINT这段代码的关键在于SIG_BLOCK和SIG_UNBLOCK的配对使用。sigprocmask(SIG_BLOCK, set, NULL)表示把 set 里的信号追加到当前阻塞集SIG_UNBLOCK表示从阻塞集中移除。实际工程里这种“临时屏蔽、再恢复”的模式常用于保护临界区。比如某段代码操作共享数据结构时不想被信号处理函数插一脚就先把相关信号屏蔽掉操作完再恢复。注意恢复的时候不要用SIG_SETMASK直接覆盖为一个空集而应该用SIG_UNBLOCK只解除自己加的那几个信号否则可能把其他线程/库设置的阻塞信号一并清掉造成隐性 bug。2.2 sigaction 的 sa_mask处理期间的“自动二次屏蔽”除了主动调用sigprocmask()信号处理过程中还有一层隐式的屏蔽很多人刚开始容易忽略。当你用sigaction()注册一个信号处理函数时默认行为是当这个信号的处理函数正在执行时同类型的信号会被自动屏蔽。这就是为什么你在 handler 里连续收到同一个信号时不会出现无休止的递归嵌套。你可以通过在sa_mask字段里追加其他信号实现在 handler 执行期间一并屏蔽它们。看这段代码#include stdio.h #include signal.h #include unistd.h #include string.h void handler(int sig) { write(STDOUT_FILENO, enter handler\n, 14); sleep(3); write(STDOUT_FILENO, leave handler\n, 14); } int main(void) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler handler; sigemptyset(sa.sa_mask); sigaddset(sa.sa_mask, SIGQUIT); // handler 执行期间额外屏蔽 SIGQUIT sa.sa_flags 0; sigaction(SIGINT, sa, NULL); sigaction(SIGQUIT, sa, NULL); printf(pid%d, press CtrlC then Ctrl\\ ...\n, getpid()); while (1) pause(); return 0; }当 SIGINT 的 handler 进入后SIGQUIT 此时再到达也不会打断它而是被挂到未决集里直到 handler 返回内核检查到 signal mask 恢复原状才继续处理 SIGQUIT。这个行为在文档里叫 “signal mask saved and restored”。换句话说每次进入 handler内核都会自动帮你保存一份旧的信号掩码handler 返回时再自动恢复。这又是一种“保存”只不过保存的是阻塞集的状态。从这个角度理解信号保存至少包含两层含义一是未决信号的保存信号来了先存账二是执行现场的保存处理函数执行期间的信号掩码快照。把这两层分清楚后面理解sigsuspend()就会容易很多。3. 实战亲手观察信号的保存、恢复与原子等待3.1 sigpending把“待办账本”打印出来上一节代码里已经出现过sigpending()它是一个容易被忽略但非常有用的调试接口。它把当前进程更准确说是当前线程的未决信号集合拷贝到用户空间。我们在排查“信号为什么迟迟没执行”时第一招就是用sigpending()看它到底有没有被保存下来。演示用法sigset_t pending; sigemptyset(pending); sigpending(pending); for (int sig 1; sig NSIG; sig) { if (sigismember(pending, sig)) { printf(signal %d is pending\n, sig); } }注意sigpending()查出的是“在阻塞期内未递达”的信号。如果信号没有阻塞、也没有特殊处理通常一产生就会立即递达你很难抓到它 pending 的状态。所以抓 pending 的前提通常是先用sigprocmask()屏蔽一组信号。一个常见面试题就是围绕着三个系统调用展开的sigprocmask、sigpending、和sigsuspend。前两个我们看完了第三个才是真正的难点也是解决竞态条件的关键工具。3.2 sigsuspend把“检查未决”和“等待信号”变成一步假设你现在要写一个父进程等待子进程退出的同步逻辑。最直观的写法是什么大概是这样先检查全局标志位如果子进程还没退出就调用pause()睡眠等待。子进程退出时发送 SIGUSR1handler 里把标志位置为 1。父进程被信号唤醒后继续干活。看起来没问题但这里藏着一个经典竞态如果你在“检查标志位”和“调用 pause()“之间子进程恰好退出了、信号恰好处理完了、标志位恰好变成 1 了然后你才调用 pause()那么这个信号你就永远错过了进程会卡死在 sleep 上。这类问题在 Unix 编程里臭名昭著教科书级的解法就是sigsuspend()。sigsuspend()做的事是原子的先把进程的 signal mask 临时替换为参数指定的值然后挂起等待信号等到某个信号处理函数返回后再把 mask 恢复为进入前的值。它把“修改 mask 等待”这两个动作合并成一步中间不会被信号插队。来看这个标准例子。父子进程通过 SIGUSR1 同步父进程阻塞 SIGUSR1 后用sigsuspend()原子等待#include stdio.h #include signal.h #include unistd.h #include sys/wait.h static volatile sig_atomic_t child_done 0; void child_handler(int sig) { child_done 1; } int main(void) { pid_t pid; sigset_t mask, oldmask; signal(SIGUSR1, child_handler); sigemptyset(mask); sigaddset(mask, SIGUSR1); pid fork(); if (pid 0) { /* 子进程睡 2 秒后通知父进程 */ sleep(2); kill(getppid(), SIGUSR1); _exit(0); } /* 阻塞 SIGUSR1保存旧 mask */ sigprocmask(SIG_BLOCK, mask, oldmask); /* 原子地暂时用 oldmask 替换当前阻塞集并等待信号 */ while (!child_done) { sigsuspend(oldmask); } /* 恢复阻塞集 */ sigprocmask(SIG_SETMASK, oldmask, NULL); wait(NULL); printf(parent sees child done\n); return 0; }这里的核心逻辑是父进程先把 SIGUSR1 阻塞住防止它在“还没进入 sigsuspend 时”就被处理掉、从而错过标志位。然后sigsuspend(oldmask)临时放开 SIGUSR1 的阻塞同时挂起。如果子进程的 SIGUSR1 在进入 sigsuspend 之前就来了没关系因为那时信号被阻塞着会保存到未决集等 sigsuspend 把 mask 换成 oldmask 后内核立刻检查未决集发现 SIGUSR1 可以递达了马上处理handler 置位sigsuspend 返回。如果信号在 sigsuspend 期间到达同样可以唤醒它。我当年第一次写这种同步逻辑时就是先写了一个“标志位 pause()”的版本结果线上偶发卡死。后来改成sigsuspend()才彻底根治。直到现在我在代码评审里看到“条件变量/自旋 pause()”这种组合都会直接建议改成sigsuspend()。提示sigsuspend()没有失败返回的说法它永远返回 -1 并把 errno 设为 EINTR。代码里不要把它当成有返回值的函数去判断成败它“正常返回”的唯一方式就是被信号打断。4. 信号处理函数里的“现场保存”errno 与可重入边界4.1 一个容易被漏掉的保存errno信号处理函数是在你程序的任意一条指令间隙被插入执行的这就带来一个很讨厌的问题你当前正在调用的系统调用可能马上要报错了结果信号插进来handler 里又调用了一个系统调用把 errno 改了。等你原来的代码从 handler 回来后再去读 errno拿到的已经不是原来的值了程序随之错误处理。正确做法是在 handler 一进来就保存 errno退出前恢复。标准模板很简单void handler(int sig) { int saved_errno errno; /* 做一些 async-signal-safe 的操作 */ errno saved_errno; }这个习惯看起来微不足道但真出问题的时候非常隐蔽。我遇到过一次一个网络服务在 EINTR 重试逻辑里反复出错日志显示 errno 一会儿是 EINTR一会儿是 EACCES排查了半天最后发现是信号 handler 里调用write()改了 errno导致主流程误判。加了一行errno保存恢复后问题再没出现过。4.2 可重入与异步信号安全handler 里到底能碰什么比 errno 更严重的是可重入问题。信号处理函数执行期间进程随时可能再次被同一个或另一个信号打断如果打断点恰好落在malloc()、printf()、sprintf()这类非异步信号安全函数的内部就可能出现堆损坏、死锁甚至崩溃。Linux 的signal-safety(7)手册页里列了一张可以用在信号处理函数里的函数白名单像read()、write()、_exit()、sigaction()、sigpending()、sigprocmask()、kill()这些都是安全的而printf()、malloc()、free()、sprintf()、strtok()等一大批和缓存、全局状态、锁相关的函数都是不安全的。所以实际项目里handler 里最稳妥的做法就一行置一个volatile sig_atomic_t标志位立刻返回。主循环看到标志位变化后再去执行真正的业务逻辑。#include stdio.h #include signal.h #include unistd.h static volatile sig_atomic_t g_flag 0; void on_signal(int sig) { g_flag 1; } int main(void) { signal(SIGINT, on_signal); while (!g_flag) { /* 干点别的活 */ sleep(1); } printf(exit by signal flag\n); return 0; }为什么必须是volatile sig_atomic_t而不是普通int因为编译器可能把循环里的g_flag优化到寄存器里导致on_signal()改了内存中的值主循环却毫不知情。volatile强制每次从内存读sig_atomic_t保证读写是原子操作。这两者组合在一起才是跨平台的安全标志位。这里还要区分两个概念可重入和线程安全。可重入函数不依赖全局静态数据可以被中断后再次进入也能正常工作线程安全是靠锁保证的一旦被信号中断锁可能被同一个线程再次申请直接死锁。所以线程安全的函数不等于可以在 handler 里调用。我见过有人信誓旦旦地说 “printf()在 glibc 里加了锁多线程安全”然后把它写进 handler结果当场死锁——同进程信号打断同线程持有锁的线程自己又去申请同一把锁不死锁才怪。5. 常见坑位速查信号保存与恢复中的问题定位5.1 一张表快速对照典型问题我平时排查信号相关问题基本靠下面这张表做初步判断效率很高。现象可能原因排查/解决方法连续发多个标准信号只处理了一次标准信号不排队bit 位图覆盖换实时信号或换管道/eventfd 计数信号被阻塞后一直不处理未调用sigprocmask(SIG_UNBLOCK,...)查阻塞集用sigpending()看是否在未决集中handler 里调用 printf/ malloc 后死机非异步信号安全函数重入破坏状态只置标志位使用 async-signal-safe 函数errno 在信号返回后变了handler 内修改了 errno进入 handler 先保存退出前恢复标志位不更新主循环卡死缺少 volatile 或 sig_atomic_t使用volatile sig_atomic_t子进程退出父进程等不到 SIGCHLD注册过自定义 SA_NOCLDSTOP或 handler 被覆盖统一用 sigaction 注册确认等待加入 SA_NOCLDWAIT 等标志用pause()等待信号偶发永久阻塞“检查标志位”和“等待”之间存在竞态改用sigsuspend()原子等待恢复信号掩码时连带清了其他信号使用了SIG_SETMASK覆盖整组用SIG_UNBLOCK精确解除保留旧 mask5.2 另一种“信号保存”保存旧的 handler 以便恢复除了上面聊的未决保存和现场保存很多资料里提到的“信号保存”其实指的是保存信号原有的处理方式然后再临时改成自己的处理函数用完再恢复回去。这也是很常见的需求。早期的signal()函数会返回之前的 handler利用这一点可以做临时替换#include stdio.h #include signal.h #include unistd.h typedef void (*sighandler_t)(int); void temp_handler(int sig) { write(STDOUT_FILENO, temp handler\n, 13); } int main(void) { sighandler_t old signal(SIGINT, temp_handler); if (old SIG_ERR) { perror(signal); return 1; } printf(temporary handler for 3 seconds...\n); sleep(3); /* 恢复原来的处理方式 */ signal(SIGINT, old); printf(restored original handler\n); sleep(3); return 0; }不过我更推荐用sigaction()来做保存和恢复因为它不仅能取到旧 handler还能把sa_mask、sa_flags一起带出来返回的信息更完整。标准写法治安一个struct sigaction old_sa在注册时传出去后面想恢复就再sigaction(sig, old_sa, NULL)。做“保存与恢复”的时候有一个原则谁保存谁恢复。如果一段代码临时接管了 SIGINT必须在退出前恢复原状否则库函数被调用者改了信号行为其他模块会跟着遭殃。我在一个 SDK 项目里就见过这种情况某个模块临时把 SIGPIPE 忽略掉退出时忘了恢复导致上层业务完全感知不到管道断开所有写入操作在沉默中失败排查起来非常费劲。5.3 多线程环境下的信号保存别让“进程级”阻塞坑了你最后补一个多线程场景。传统信号行为是进程级的signal()或sigaction()注册的 handler 对整个进程的所有线程生效。但是sigprocmask()的行为在多线程里要格外小心——它操作的是调用线程的信号掩码而不是整个进程。在 Linux 上每个线程都有自己的 signal mask。发一个未指定线程的信号给进程时内核会选择进程中任意一个未被阻塞该信号的线程来递达。所以多线程程序里如果想用信号做线程间通知正确姿势一般是主线程先全局阻塞目标信号。创建一个专用线程调用sigwait()或sigwaitinfo()等待并取出信号。业务线程正常干活信号统一由这个线程消费。这样做的好处是信号的“保存”和“消费”都在一个确定的地方不会出现“信号递达给了不相关的线程结果某个线程的阻塞状态被打断”这种混乱。sigwait()处理信号时信号不会走 handler而是像从队列里取数据一样被取走天然避免了重入问题。最后说点实在的我最早接触信号保存这一块时也对着sigpending、sigprocmask、sigsuspend这三个名字绕了很久总觉得抽象。后来养成一个习惯遇到信号行为异常先在纸上把“阻塞集”和“未决集”两个集合画出来把当前进程处于什么状态标清楚问题基本立刻现形。调信号相关的问题也建议直接用strace -e tracesignal -p 进程号跟踪系统调用能看到rt_sigprocmask、rt_sigpending、rt_sigsuspend这些底层调用是不是按你预期发生的。我个人还有个笨办法写一个 50 行以内的小实验程序把信号阻塞、发送、查看 pending、解除阻塞这四个步骤挨个打日志所有的概念跑一遍就通了。信号这东西看文档十遍不如自己动手指一次。
返回列表