Linux系统篇31——信号(三):信号被屏蔽就等于消失?内核说:它一直挂着等你

📅 发布时间:2026/9/13 23:31:19
Linux系统篇31——信号(三):信号被屏蔽就等于消失?内核说:它一直挂着等你
本文收录于「流浪」的系列专栏系列专栏直达链接 Linux系统进入专栏 →⚙️ C进入专栏 → 数据结构与算法进入专栏 → Python进入专栏 → LangChain LangGraph进入专栏 →️ MySQL 数据库进入专栏 → Git 工具进入专栏 → 计算机网络进入专栏 → AI进入专栏 → 大厂面试、八股进入专栏 → 学习筑基专栏进入专栏 → 博客主页流浪 原创首发于 CSDN前言篇29 把信号是什么、从哪来、发给谁铺完篇30 又钻进内核讲清了怎么存位图、怎么发只有 OS 能改、怎么收handler 三选一。这一篇接着往下走一步回答两个一直悬着的问题信号被屏蔽之后到底待在哪它会不会丢全篇拆四块——三态递达 / 未决 / 屏蔽怎么分清、sigprocmask 怎么改屏蔽表、sigpending 怎么把挂起的信号读出来、以及普通信号为什么会丢而实时信号不丢。至于信号递达之后进程怎么结束、core 文件怎么落盘、9 号为什么拦不住那是篇32信号四的主场本篇不抢。一、信号的三种形态递达 / 未决 / 屏蔽1.1 三个概念一眼分清先把三个最容易被混着用的词钉死递达Delivery真正去执行那个处理动作——默认 / 自定义 / 忽略三选一落地。未决Pending信号已经产生了但还没轮到处理就先挂着——在内核位图里对应那一位是1。屏蔽 / 阻塞Block这个信号被挡住了暂时不递送。一个生活类比pending 是快递已到你小区驿站还没送上门block 是你跟驿站说先别送递达是快递员真把件送到你手上、你当面处理掉。信号在内核中的表示所以所谓的signal注册进程处理信号的方法本质上就是修改handler表block屏蔽控制信号能不能递达handler 决定递达之后怎么做。屏蔽的优先级更高只要 block1信号会进入 pending 未决队列handler 不会执行直到屏蔽解除。1.2 屏蔽 vs 忽略最容易混的一对这是面试高频坑两句话分清屏蔽 不递达信号压根走不到处理这一步一直挂在 pending 里等着。忽略 递达之后选了 SIG_IGN流程完整走完了只是处理动作是装没看见。关键结论阻塞和忽略是两回事。只要信号被阻塞就不会递达而忽略是递达之后才发生的一种处理动作。所以阻塞信号也叫屏蔽信号。1.3 内核里分别对应哪个字段这三个概念落到内核就是三个我们已经见过的字段屏蔽集 →task_struct.blocked位图未决集 →task_struct.pending线程私有signal_struct.shared_pending线程组共享处理动作表 →sighand_struct.action[_NSIG]【衔接篇30】这三个字段的真实内核源码Linux v6.6在篇30 §2.4已经贴过本篇不重贴。而且篇30 §2.5 告诉过你一个更有意思的事它们能在/proc/pid/status里直接看到——SigBlk就是 blocked、SigPnd就是线程私有 pending、ShdPnd就是线程组共享 pending。也就是说本篇讲的所有概念你都能在自己机器上cat出来眼见为实。1.4 递达的适当时机到底是什么时候篇30 §1.1 讲过信号是异步的不能到了就立刻处理必须先存着、等合适时机。那个合适时机就是进程从内核态返回用户态的那一刻——内核在这个点上回头检查 pending 位图有未屏蔽的挂起信号就处理掉。二、sigprocmask改 block 表的标准做法2.1 函数原型三个参数逐个拆#include signal.h int sigprocmask(int how, const sigset_t *set, sigset_t *old_set);how怎么改加进去 / 拿掉 / 整个覆盖set拿什么改——你要屏蔽或解除屏蔽的那组信号old_set输出型参数——把改之前的旧掩码存出来以便以后恢复2.2 how 的三个值与对应位运算这三个值本质就是对blocked位图做不同的位操作how 取值等价位运算含义SIG_BLOCKblocked | set把 set 里的信号加进屏蔽集SIG_UNBLOCKblocked ~set从屏蔽集里移除set 里的信号SIG_SETMASKblocked set整个覆盖屏蔽集2.3 old_set 是输出型参数经典的保存—恢复模式old_set存在的意义就一句话改之前先把原样存下来改完事后再原样还回去不破坏调用者原本的屏蔽设置。标准写法长这样sigset_t new_mask, old_mask; //初始化一个信号集把集合里所有信号全部清空 sigemptyset(new_mask); //往集合里添加2号信号 sigaddset(new_mask, SIGINT); sigprocmask(SIG_BLOCK, new_mask, old_mask); // 1. 屏蔽并把旧掩码存进 old_mask /* 2. 关键区这段代码不希望被 SIGINT 打断 */ sigprocmask(SIG_SETMASK, old_mask, NULL); // 3. 用存下的旧掩码精确恢复这个临时屏蔽 → 干关键活 → 精确恢复的三段式是保护临界区的标准套路面试常考。2.4 内核里改的到底是哪个字段就是task_struct.blocked那个位图。【衔接篇30 §3.2】篇30 讲过内核数据改不了只有 OS 能改。sigprocmask是你唯一能合法改自己 blocked 位图的入口——但注意它仍然是陷进内核、由内核去写你自己的 blocked 位图不是你在用户态直接改内存。这就是能改自己的和能改别人的的根本区别你能改自己的 block 表但你永远改不了别人的 pending 位图。代码演示在下一节三、sigpending 与 sigset_t读出被屏蔽但已挂起的信号3.1 sigpending 读的是哪份数据#include signal.h int sigpending(sigset_t *set);它返回的是当前挂起、但还没递达的信号集合 线程私有 pending ∪ 线程组共享 shared_pending的并集。【衔接篇30 §2.5】它和/proc/self/status里的SigPnd是同一份数据的两个出口一个走系统调用读一个走 /proc 文本读。这正是 1.2 那个结论的实证——信号被屏蔽了照样能被看见它就老老实实待在 pending 位图里。3.2 sigset_t 的五个操作函数集合不能自己手搓得用这五个标准函数它们就是常说的五种操作方式函数作用sigemptyset清空集合全 0sigfillset填满集合所有信号都置 1sigaddset往集合里加一个信号sigdelset从集合里删一个信号sigismember判断某个信号在不在集合里这五个函数的原型来自 glibc / POSIX 标准#include signal.h /* 把集合 set 清空全 0用于先清再装的开头 */ int sigemptyset(sigset_t *set); /* 把集合 set 填满所有信号都置 1用于先全装再剔除的开头 */ int sigfillset(sigset_t *set); /* 把编号为 signum 的信号加入集合 set位图对应位置 1 */ int sigaddset(sigset_t *set, int signum); /* 把编号为 signum 的信号从集合 set 里移除位图对应位置 0 */ int sigdelset(sigset_t *set, int signum); /* 判断 signum 是不是在集合 set 里位图对应位是不是 1返回 1在 / 0不在 */ int sigismember(const sigset_t *set, int signum); /* * Feature Test Macro Requirements for glibc见 feature_test_macros(7) * sigemptyset(), sigfillset(), sigaddset(), sigdelset(), sigismember(): * _POSIX_C_SOURCE */几个用起来容易踩的小坑返回类型是 int不是 void这五个函数调用失败会返回-1并设置errno虽然实际几乎不会失败但严谨点应当判一下。不要图省事用memset(set, 0, sizeof(set))代替 sigemptysetPOSIX 只规定了 sigset_t 的位图布局和几个接口没规定它必须不能有填充字节。直接 memset 在某些实现下可能留下垃圾位sigemptyset 才是 portable 的写法。_POSIX_C_SOURCE宏glibc 下要看到这个完整原型声明必须#define _POSIX_C_SOURCE 199309L或更大版本号放在所有头文件之前否则老版本头文件可能只暴露一部分函数或返回类型不匹配。3.3 为什么必须先造集合再传参因为sigprocmask/sigpending这些接口收的都是sigset_t *指针——你得先用上面五个函数把集合准备好再把地址传进去。顺序永远是先 sigemptyset 清空 → 再 sigaddset 加你要的信号 → 最后传给接口。#include iostream #include signal.h #include unistd.h void PrintPending(sigset_t pending) { printf(我是一个进程(%d), pending: , getpid()); for (int signo 31; signo 1; signo--) { if (sigismember(pending, signo)) { std::cout 1; } else { std::cout 0; } } std::cout std::endl; } void handler(int sig) { std::cout ####################### std::endl; std::cout 递达 sig 信号! std::endl; sigset_t pending; int m sigpending(pending); PrintPending(pending); // 0000 0010(处理完2号才回被设置为0)0000 0000(执行handler方法之前2对应的pending已经被清理了) std::cout ####################### std::endl; } int main() { signal(SIGINT, handler); // 1. 屏蔽2号信号 sigset_t block, oblock; sigemptyset(block); sigemptyset(oblock); sigaddset(block, SIGINT); // 已经对2号信号进行屏蔽了吗没有 // for(int i 1; i32; i) // sigaddset(block, i); int n sigprocmask(SIG_SETMASK, block, oblock); (void)n; // 4. 重复获取打印过程 int cnt 0; while (true) { // 2. 获取pending信号集合 sigset_t pending; int m sigpending(pending); // 3. 打印 PrintPending(pending); if (cnt 30) { // 5. 恢复对2号信号的block情况 std::cout 解除对2号的屏蔽 std::endl; sigprocmask(SIG_SETMASK, oblock, nullptr); } sleep(1); cnt; } return 0; }四、信号的丢失什么时候丢为什么有的不丢4.1 位图 1→0 的清零时机先纠正一个直觉误区pending 位图不是信号发完就清零。真正的时机是信号准备递达的那一刻内核才把 pending 位图里对应的那一位从 1 清成 0。在那之前这一位一直亮着——哪怕信号是很久以前发过来的。将上一节的SigPrint函数改为下面的代码void handler(int sig) { std::cout ####################### std::endl; std::cout 递达 sig 信号! std::endl; sigset_t pending; int m sigpending(pending); PrintPending(pending); // 0000 0010(处理完2号才回被设置为0)0000 0000(执行handler方法之前2对应的pending已经被清理了) std::cout ####################### std::endl; }【衔接篇30 §2.4】篇30 贴过的struct sigpending里那个sigset_t signal位图就是这么被置位信号到达和清位信号递达的。本篇补的正是它的时间维度。4.2 普通信号连发多次只记一次 → 丢位图只有 0 和 1不计数。你连着发 5 次 SIGINT位图第 2 位也只是从 0 变成 1本来就是 1 则不变进程最终只处理一次——后面那 4 次就丢了。4.3 同时来多个不同信号各占一位 → 不丢不同信号编号对应不同的 bit互不冲突。SIGINT(2) 和 SIGUSR1(10) 同时到就是第 2 位和第 10 位各置 1两个都会被处理不会互相覆盖。4.4 实时信号为什么不丢实时信号除了置位图还会往sigqueue链表里挂一个节点节点里带着siginfo数据。来 5 次就挂 5 个节点一个都不会少。一句话对比普通信号只回答有没有实时信号还能回答来了几次、每次带的什么数据。【衔接篇30 §5.3】篇30 讲过这个内核差异的实现层位图置位 vs sigqueue 链表挂节点本篇补的是它带来的直接后果——丢不丢。两者是同一件事的因和果。4.5 那我想要不丢有办法吗先说结论普通信号做不到——位图不计数是它的设计取舍省空间、快不是 bug没有任何参数能把它变成计数器。现实里有两条路换实时信号用sigqueue()发配sigaction的SA_SIGINFO收来几次处理几次还能每次带上数据。可用编号从SIGRTMIN起——glibc 下它是34而不是 32因为 NPTL 线程库内部占用了 32、33 两个。别指望信号计数换通道真要可靠地传事件发生了几次用管道、共享内存里的计数器、或者自己维护队列更合适。信号天生只适合通知有事发生了不适合传发生了多少。这也是面试里常被追问的一层问完为什么会丢下一句基本就是那怎么让它不丢答换实时信号 sigqueue就够了。五、小结两句话收住本篇屏蔽 ≠ 忽略屏蔽是不递达忽略是递达之后选 SIG_IGN被屏蔽的信号不会消失它一直待在 pending 位图里直到解除屏蔽才递达。改 block 用 sigprocmask、读 pending 用 sigpending这是一对标准接口配合 sigset_t 的五个操作函数使用old_set是输出型参数专门服务于保存—恢复模式。普通信号连发会丢位图不计数实时信号不丢额外挂 sigqueue 节点还能带数据。本篇回答的是信号被屏蔽时去哪了、会不会丢篇32信号四接着回答信号真递达之后进程怎么结束——Term 与 Core 两种归宿、core 文件怎么开怎么用、9 号信号为什么拦不住。两篇合起来才是完整的信号生命周期后半段。六、面试官爱问带答案6.1 阻塞屏蔽和忽略的本质区别是什么答阻塞是不递达——信号压根走不到处理那一步一直挂在 pending 位图里忽略是递达之后处理动作选了 SIG_IGN流程已经完整走完。一个是半路被拦下一个是送到了但不当回事。6.2 sigprocmask 的 how 有哪三个值答SIG_BLOCKblocked | set加入屏蔽、SIG_UNBLOCKblocked ~set解除屏蔽、SIG_SETMASKblocked set整个覆盖。6.3 old_set 有什么用答它是输出型参数把修改前的旧掩码存出来事后精确恢复。典型场景是保护临界区临时屏蔽 → 执行不希望被打断的代码 → 用SIG_SETMASK把旧掩码写回去不破坏调用者原本的屏蔽设置。6.4 普通信号连发为什么会丢实时信号为什么不丢答普通信号只靠sigset_t位图记录位图只有 0/1、不计数连发多次只会被合并成一次实时信号除了置位图还会往sigqueue链表挂节点带siginfo数据来几次挂几个所以能排队、能带数据、不丢。6.5 sigset_t 能不能直接 memset 清零代替 sigemptyset答不能至少不该。POSIX 只规定了 sigset_t 的位图语义和那五个操作接口没保证它内部没有填充字节。直接memset(set, 0, sizeof(set))在当前 glibc 上大概率能跑对但换实现就可能留下垃圾位。要 portable 就用 sigemptyset。6.6 被屏蔽的信号解除屏蔽之后会怎么样答它会立刻被递达——准确说是在最近一次从内核态返回用户态的检查点上被发现并处理掉篇30 §1.1 讲过这个时机。如果期间只来了一次就处理一次如果连着来了 5 次同一个普通信号解除后仍然只处理一次因为位图早就被合并了。实时信号则排着队依次处理。你如果在自己机器上跑出了不一样的结果或者某个点没看懂评论区直接说我补图补例子。也欢迎把本篇和篇29信号一、篇30信号二串起来复习并接着看篇32信号四——四篇是同一条主线拆成的对照着看最清楚。