news 2026/10/20 15:42:35

Linux信号机制核心:阻塞信号集与未决信号集详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux信号机制核心:阻塞信号集与未决信号集详解

做后台服务、写Linux工具或者搞嵌入式开发的同学,十有八九都在信号处理这个模块上栽过跟头。表面上看,signal、kill、raise 这几个接口都不复杂,但一旦牵扯到“信号发出去之后到底去哪了”“为什么同一个信号发了好几次却只有一次效果”“为什么阻塞了信号程序还是退出了”,很多人就懵了。这些问题的根源,都指向 Linux 信号机制里两个最核心的概念:阻塞信号集与未决信号集。这篇博文我会从内核处理信号的三步流程讲起,把这两个信号集的本质、相关函数、代码实验和工程避坑一次性说清楚。不管是刚开始接触系统编程的初级读者,还是在排查线上诡异进程表现的中级工程师,都能在这里找到你需要的答案。

1. 先弄懂信号的一生:产生、未决与递达

1.1 信号从哪里来,到哪里去

信号的本质,是内核向进程发送的一种软件中断通知。它解决的核心问题是:让进程能够被异步地告知“某个事件发生了”。触发信号的来源很多,常见的有这么几类:用户按下 Ctrl+C 触发的 SIGINT,程序自身非法访问内存触发的 SIGSEGV,调用 kill 或 raise 主动发送的信号,alarm、setitimer 这类定时器到点产生的信号,以及硬件浮点错误引发的 SIGFPE。

一个信号从诞生到最终被进程处理,在内核视角里要经历三个阶段:产生(generation)、未决(pending)、递达(delivery)。产生指的是信号已经被内核记录,等待交给目标进程。未决指的是信号产生了、但进程暂时不接收,只能先挂起等待。递达则是指进程真正接收到了这个信号,于是默认动作、忽略、捕获处理函数三选一,开始干活。

这里有另一个容易混淆的概念是递达后的处理方式:忽略、捕获、默认动作。忽略是“收到后当没发生过”;捕获是“收到后调用我注册的 handler”;默认动作则是让内核按标准行为处理,比如 SIGINT 默认终止进程、SIGCHLD 默认忽略、SIGCONT 默认继续执行。

1.2 三个集合的关系:阻塞、未决、递达动作

理解信号机制,关键是要在心里装下一张“流程图”。信号产生后,内核不会立刻无条件把它丢给进程,而是先做一个检查:这个信号是否在当前进程的阻塞信号集里?阻塞信号集是进程维护的一份“屏蔽名单”,名单里的信号当前不允许被递达。如果信号不在名单里,就直接递达,该干嘛干嘛。如果信号在名单里,内核就把它放进未决信号集中,相当于给这个挂着,等到名单里移除该信号、不再阻塞的时候,再真正递达给进程。

所以阻塞和未决之间是因果联动的关系:阻塞是前提,未决是结果。这也解释了为什么很多时候你会观察到某个信号“被丢掉了”——它其实没被丢掉,只是躺在未决信号集里等一个解封的机会。

值得多说一句的是,进程对信号设置的“忽略”动作,和“阻塞”完全是两码事。忽略是信号递达后直接丢弃;而阻塞是信号还放在未决状态中等候。具体区别我放到下一节细讲。

1.3 阻塞不等于忽略,这是第一个大坑

我见过不少开发者把“阻塞”理解成“忽略”,这是信号机制里第一个容易踩中的坑。忽略意味着信号递达后不做任何处理,动作已经发生完了;而阻塞意味着信号根本没有递达,被暂时压在未决信号集里。

举例说明:如果你设置 SIGINT 为 SIG_IGN,用户再怎么按 Ctrl+C 都没用,因为信号一旦到达,内核一看处理方式是忽略,就直接抛弃了。但如果你把 SIGINT 加入阻塞信号集,情况就不一样:信号产生后会进入未决状态,你随时可以用 sigpending 查到它的存在,等你解除阻塞,它又立刻被递达出来。

更关键的是,SIGKILL 和 SIGSTOP 这两个信号是例外中的例外。它们既不能被捕获,也不能被忽略,更不可能被阻塞。哪怕你尝试把它们添加进阻塞信号集,内核也会静默忽略这个请求。所以在设计程序时不要想着用屏蔽信号的方式来阻止 kill -9,那是做不到的。

2. 两个信号集的本质:内核位图与排队规则

2.1 阻塞信号集:内核里的“黑名单位图”

阻塞信号集在别的书里也被叫做信号屏蔽字。这个名字很直白,就是一组位图,每一位对应一个信号编号。某个位为 1,就表示当前进程不想收到这个信号。在 Linux 里,一个信号集用 sigset_t 类型表示,底层是一个足够容纳所有信号位的无符号整数数组。

进程什么时候会用到阻塞信号集?最常见的是做临界区保护。比如某段代码正在更新关键数据结构,此时如果来了 SIGINT,handler 里也去操作同一份数据,就会产生不可预期的并发问题。在进入临界区之前,把可能干扰的信号全部阻塞掉,处理完再解除阻塞,是一种经典做法。

操纵阻塞信号集的接口是 sigprocmask,它支持三种模式:把新信号并进原阻塞集(SIG_BLOCK),把信号从原阻塞集移除(SIG_UNBLOCK),以及直接用新集合替换旧集合(SIG_SETMASK)。具体用法我在后文展开。

2.2 未决信号集:传达室里的“待签收快递”

未决信号集则更像是小区传达室里的一堆待签收快递。快递已经到了,但因为收件人暂时不方便签收,只能先放在传达室,等收件人说“现在可以收了”,再一次性送到手上。

这个未决信号集同样是一个位图,每一个信号编号对应一位。只有当信号被阻塞、还没递达的情况下,这一位才会被置为 1。用 sigpending 接口就能查询当前进程的未决信号集里有哪些信号。

这里需要特别强调:sigpending 只能查询,不能修改。你拿到了未决信号集,看着里面有 SIGINT,但你没法直接通过 sigpending 把这一位清零。正确做法是调用 sigprocmask 解除 SIGINT 的阻塞,让内核把信号递达出去,之后这一位才被清零。

2.3 标准信号与实时信号:一个不排队,一个排队

很多人没注意到,Linux 的信号其实分成两大阵营:传统标准信号(1 到 31)和实时信号(从 SIGRTMIN 到 SIGRTMAX)。这两者在未决信号集里的行为差异巨大。

标准信号是“不排队”的。什么意思呢?如果 SIGINT 已经处于未决状态,你再一次发送 SIGINT,内核不会在某个队列里再记一笔,而是继续保留未决位图为 1。等解除阻塞时,进程只会收到一次 SIGINT。这有点像一个快递驿站,同一个发件人同时寄了十个包裹,但驿站只放一个取件码在这,你说亏不亏。

实时信号则完全不一样,它是带编号、带顺序的排队。同一个实时信号被发送多次,未决队列里就会有多个条目,解除阻塞后按发送顺序逐个递达。实时信号存在的意义就是弥补标准信号不排队的不足,常用于实时性要求高的应用场景。当然,内核也不会无限排队,超过限制后新到的实时信号可能被丢弃。

2.4 多线程中的两个信号集,细节要看清

如果程序是多线程的,信号集的语义会比单线程复杂一个量级。每个线程都有一套自己的阻塞信号集,一个线程调用 pthread_sigmask 修改的是当前线程的屏蔽名单,而不是整个进程的。另一方面,未决信号集的情况分两种:进程级未决信号集记录发给整个进程的信号;线程级未决信号集记录定向发送给某个线程的信号。

当一个信号发送给进程时,内核会从所有线程里挑选一个“还没有屏蔽该信号”的线程来递达。如果所有线程都屏蔽了它,这个信号就会掉入进程级未决信号集。所以多线程里的经典坑是:你在主线程设置了阻塞,但工作线程没继承同样设置,信号照样被递达,导致你反复测试都得不到预期结果。这些问题我会放在第 5 章重点展开。

3. 核心函数实操:sigprocmask 与 sigpending 怎么用

3.1 信号集操作五件套:先学会“编辑名单”

在调用 sigprocmask 和 sigpending 之前,必须先学会操作 sigset_t 类型。sigset_t 对用户来说基本等价于一个位图,你不能直接拿 = 给信号集赋值,也不能用位运算自己置位,必须用标准库提供的五个接口:

int sigemptyset(sigset_t *set); int sigfillset(sigset_t *set); int sigaddset(sigset_t *set, int signo); int sigdelset(sigset_t *set, int signo); int sigismember(const sigset_t *set, int signo);

sigemptyset 负责把整体清成 0,sigfillset 把所有信号位全部置 1,sigaddset 把指定信号添加到集合,sigdelset 则把指定信号移除出去,sigismember 用来检测一个信号是否为集合成员。几乎每次你都应该先调用 sigemptyset 再 sigaddset,否则未初始化的位图可能残留垃圾位。我见过有同学声明了一个 sigset_t 变量就直接 sigaddset,结果屏蔽了莫名奇妙的信号,程序表现诡异,查了半天才发现是没初始化。

3.2 sigprocmask:给进程加一道“信号门禁”

sigprocmask 是修改阻塞信号集的唯一入口,原型如下:

int sigprocmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);

参数 how 决定 set 的语义,我整理成了一张表方便对照:

how 取值含义等价操作
SIG_BLOCK把 set 里的信号并入当前阻塞集阻塞集 = 原阻塞集
SIG_UNBLOCK把 set 里的信号从当前阻塞集移除阻塞集 = 原阻塞集 & ~set
SIG_SETMASK用 set 整体替换当前阻塞集阻塞集 = set

第三个参数 oset 如果非空,内核会把修改前的旧阻塞集保存到里面,方便你事后恢复现场。常见写法是先用 sigprocmask(SIG_BLOCK, &newmask, &oldmask) 把原集合存下来,干完活再 sigprocmask(SIG_SETMASK, &oldmask, NULL) 一次性还原。

需要注意,SIGKILL 和 SIGSTOP 不能出现在阻塞集里,如果你在 set 里加了它们,调用会被内核忽略掉,不会报错,也不会生效。

3.3 sigpending:看一眼“传达室里的包裹”

sigpending 的用途是查询当前进程中处于未决状态的信号集合。原型如下:

int sigpending(sigset_t *set);

传出参数 set 会被填充为当前未决信号集。注意这个接口只能做查询,不能直接修改。前面说过的场景,信号被阻塞后你调 sigpending,得到位图中 SIGINT 位为 1;一旦解除阻塞,信号递达结束后,你再查,这一位就变成 0 了。

sigpending 不需要你指定查哪个线程,内核会返回调用线程视角下的未决状态。在单线程程序里,它就是进程级未决集;在多线程程序里,情况要和上一节的知识点结合分析。

3.4 sigsuspend:原子化的“等信号”姿势

除了修改阻塞集、查询未决集,还有一个经常配套使用的接口 sigsuspend。它解决的是“等待信号递达”过程中的竞态问题。想想看,常规做法可能是先解除阻塞,再调 pause() 挂起等信号。但这样就存在一个时间窗口:信号可能在“解除阻塞”之后、“pause()”之前到达,而如果信号在 pause 之前被处理完了,pause 就永远等不到下一个信号,程序会一直卡住。

sigsuspend 的签名是:

int sigsuspend(const sigset_t *mask);

它会临时把当前进程的阻塞信号集替换成 mask,然后挂起等待。只有 mask 里没有阻塞的那些信号才能唤醒进程。这套流程原子的,不会出现先解除再等待的窗口期。它在实现“等待某个特定信号到达再继续初始化”的场景时非常好用。

4. 完整实验:阻塞 SIGINT 后观察未决状态变化

4.1 实验思路:让 SIGINT 经历一次“被扣押再释放”

单纯讲理论很难建立感觉,我建议读者把下面的小实验亲手跑一遍。实验目标很清晰:先把 SIGINT 加入阻塞集,然后通过 raise 向进程发送 SIGINT,观察它在未决信号集里的出现;再连续发送几次,验证标准信号不排队;最后解除阻塞,确认 SIGINT 只递达一次。

为了让进程不被默认动作干掉,我会给 SIGINT 注册一个自定义处理函数。另外还要注意一个细节:printf 默认是行缓冲,在重定向到文件或管道时输出会攒到一起才冲刷,而信号又在随时打断执行流,这会导致你看不到实验现象。所以我在程序开头把 stdout 设置为无缓冲,确保每次 printf 都能立刻刷出去。

4.2 完整代码:建议直接抄下来跑一遍

#include <stdio.h> #include <signal.h> #include <unistd.h> static int delivered = 0; static void sigint_handler(int signo) { delivered++; printf("[handler] catch SIGINT, delivered = %d\n", delivered); } static void print_pending(const char *tag) { sigset_t pending; sigemptyset(&pending); if (sigpending(&pending) == -1) { perror("sigpending"); return; } printf("%-32s SIGINT pending = %d\n", tag, sigismember(&pending, SIGINT)); } int main(void) { sigset_t set, oldset; setvbuf(stdout, NULL, _IONBF, 0); signal(SIGINT, sigint_handler); print_pending("initial"); sigemptyset(&set); sigaddset(&set, SIGINT); if (sigprocmask(SIG_BLOCK, &set, &oldset) == -1) { perror("sigprocmask block"); return 1; } print_pending("after SIG_BLOCK"); raise(SIGINT); print_pending("after raise #1"); raise(SIGINT); print_pending("after raise #2"); if (sigprocmask(SIG_SETMASK, &oldset, NULL) == -1) { perror("sigprocmask restore"); return 1; } print_pending("after unblock"); sleep(1); printf("delivered total = %d\n", delivered); return 0; }

代码逻辑很直白:注册 handler,阻塞 SIGINT,发两次信号,每次都查询未决集,最后恢复阻塞集。这里恢复用的是 SIG_SETMASK 和 oldset,等同于把之前阻塞的信号全部解除,并且顺便保留现场。

4.3 运行结果:现象比想象中更说明问题

编译运行后,我的输出如下:

initial SIGINT pending = 0 after SIG_BLOCK SIGINT pending = 0 after raise #1 SIGINT pending = 1 after raise #2 SIGINT pending = 1 [handler] catch SIGINT, delivered = 1 after unblock SIGINT pending = 0 delivered total = 1

第一个值得注意的点:raise(SIGINT) 之后,进程并没有马上崩溃或调用 handler,因为 SIGINT 还在阻塞集里,它被压进了未决集,pending 位变成 1。第二次 raise 之后 pending 仍然是 1,彻底验证了标准信号不排队:同一个信号不管发多少次,只要未决位是 1,就不会再变多。

第二个值得注意的点:执行完 sigprocmask 恢复后,还没回到下一行 print_pending,信号就已经递达了。也就是说,从解除阻塞那一刻起,内核立刻检查未决集并把 SIGINT 派发到用户态,handler 先执行完,才继续执行后面的代码。所以 “after unblock” 这行打印前会先看到 handler 输出。

整个过程很好地展示了信号状态迁移的完整闭环:产生 -> 因为阻塞而未决 -> 解除阻塞 -> 递达 -> 处理完毕。

4.4 把标准信号换成实时信号看区别

顺手把上面的实验扩展开,把 SIGINT 换成 SIGRTMIN,用 kill 或 raise 发送两次,你会发现两次 raise 后未决集里虽然 sigismember 还是 1,但真实递达次数会变成 2。这是因为实时信号在未决状态下是排队的,内核为同一个实时信号维护了一个队列,解除阻塞后逐个递达。

如果你有兴趣,还可以用 sigpending 观察到实时信号和标准信号的差异并不直观,因为 sigpending 只给一个位图,看不到数量。要连续递达两次,最直接的方法是 handler 里打印 counter,看最终 delivered total 是否为 2。

5. 常见问题与排查技巧实录

5.1 信号“丢失”了?大概率是被标准信号合并了

经常有人反馈:“我连续 kill 了 5 次 SIGUSR1,怎么进程只收到 1 次?” 这不是内核吞了你的信号,也不是 handler 写得有问题,而是标准信号根本不排队。一个标准信号只要处于未决状态,后续同样的信号都会被视为“已经有一个了”,不会叠加上去。解决思路有两个层面:如果只是要计数通知,就用实时信号;如果必须用标准信号,那就别指望它帮你做次数量统计,改进思路是让信号的产生方把事件数据写入管道或消息队列,信号本身只做“提醒”。

我自己的习惯是:设计进程间通信时,把标准信号当成“事件触发器”,而不是“数据传输通道”。要传具体次数或带参数据,老老实实用 socketpair、管道或者消息队列。

5.2 sigpending 查不到未决信号,可能信号早就递达了

另一个高频问题:明明阻塞了,为什么 sigpending 查出来是 0?先别怀疑接口,检查你的上下文顺序。如果信号是在你已经解除阻塞之后才发送的,那它直接就走递达流程,根本没机会进未决集。还有可能是信号被设置成了 SIG_IGN,内核会在产生阶段直接丢弃被忽略的信号,不会放入未决集。最后再确认一下发送方向:发送给了整个进程还是特定线程?在多线程环境下,信号如果被某个未阻塞线程接收并处理掉,进程级未决集自然就为空。

排查时建议加一套打印:先打印当前阻塞集里有没有这个信号,再打印未决集里有没有,最后打印 handler 有没有被调用。三步一对照,问题出在哪一步立刻清楚。

5.3 read、write 总是被信号打断,返回 EINTR 怎么办

信号递达还有一个隐藏副作用:它会打断某些阻塞型系统调用。比如程序正阻塞在 read 上等数据,此时一个信号递达且调用了 handler,read 会返回 -1,errno 被设为 EINTR。很多刚接触信号编程的同学第一次遇到会认为设备出错了,其实只是被信号中断了。

处理姿势有两种。一是使用 sigaction 注册 handler 时加上 SA_RESTART 标志,内核会在信号处理完后自动重启部分系统调用,read 就继续阻塞等待了。二是自己处理 EINTR,循环里碰到 EINTR 就重试调用。第一种更省心,但要注意并非所有系统调用都会被 SA_RESTART 自动重启,比如 select、poll 这类在某些内核版本上就不会自动恢复,所以“读返回 -1 + EINTR 就重试”这个思路依然值得保留。

5.4 多线程程序里,信号处理为何总是表现诡异

多线程信号处理是重灾区,常见表现是:你觉得已经屏蔽了某个信号,但进程还是疯了似的退出;或者 handler 里访问全局变量时偶发错乱。原因就是前面提到的:每个线程有自己的阻塞集,信号发往进程后内核任意挑一个没屏蔽它的线程递达。你以为整个进程都屏蔽了,实际上只有设置线程屏蔽了。

工程上的推荐做法不是每个线程都去调 sigprocmask,也不是指望信号 handler 在任意线程里安全运行,而是引入一个专门的信号处理线程,配合 sigwait 来取信号。思路是:程序一开始,把所有需要处理的信号全部阻塞,之后新线程继承初始阻塞集;然后专门起一个线程调 sigwait,循环取出信号再做统一处理。这样做的好处是信号处理逻辑集中且线程安全,不用在 handler 里战战兢兢地只调用异步信号安全函数。

要是非要在 handler 里做事情,一定要遵守“异步信号安全”的原则。不要在 handler 里调用 printf、malloc、free 这类可能加锁或者内部状态不安全的函数,这是很多内存故障、死锁问题的元凶。安全的做法是 handler 里只做一件事:往一个自管道或 eventfd 里写一个字节,然后让主循环通过 IO 复用机制去读这个通知并做后续处理,也就是经典的 self-pipe trick。

5.5 避坑清单速查表

场景关键注意点
使用 sigset_t 前必须先 sigemptyset,否则栈上垃圾值导致误屏蔽
sigprocmask 的 how搞混 SIG_BLOCK 与 SIG_SETMASK 会覆盖掉已有阻塞集
SIGKILL / SIGSTOP不能阻塞、不能捕获、不能忽略
标准信号重复发送未决期间重复信号会被合并,只递达一次
多线程环境使用 pthread_sigmask,每个线程阻塞集独立
多线程信号捕获优先用专用线程 + sigwait,少写 handler
handler 内操作避免 printf 类函数,用 write 或 self-pipe
系统调用被中断处理 EINTR 重试,或 sigaction 加 SA_RESTART
阻塞解除瞬间信号递达会立刻发生,注意时序

把上面这些坑填平之后,你再去回看各种诡异现象,会发现大部分都不是玄学,而是信号状态机在多线程、阻塞与未决转换下的必然结果。

我个人在实际操作中的体会是:信号机制不像内存管理或者线程同步那么显眼,但它往往藏在线上问题的最深处。很多看起来“隔三差五随机偶发”的故障,真凶都是信号处理时序不对。建议每个项目在启动阶段就把信号处理方案定下来:哪些信号阻塞、哪些信号交给 sigwait、哪些信号用 sigaction 捕获、哪些瞬间必须屏蔽中断,这比出了问题再来翻内核文档高效得多。最后留一个小技巧:当你怀疑某个信号是否被阻塞时,不要光看代码,直接向进程不停发这个信号,同时用 cat /proc/进程号/task/线程号/status 查看 SigBlk 和 SigPnd 两列,状态一目了然。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 15:42:01

mtgsig3.0签名逆向分析:从静态定位到动态Hook的完整方法论

最近总有人私下找我&#xff0c;问“某外卖App的mtgsig3.0签名到底怎么解出来的”。说实话&#xff0c;网上能找到的帖子要么只丢一句“hook一下so就拿到了”&#xff0c;要么直接贴几段不知哪来的伪代码&#xff0c;看得人一头雾水。我前前后后花了不少时间做了几轮逆向分析&a…

作者头像 李华
网站建设 2026/10/20 15:41:47

从零搭建PPT自动化生成工具:技术选型、核心实现与踩坑实录

1. 从零搭建一个PPT自动化生成工具&#xff0c;我为什么选了这条路第一次听到“ppt-master”这个命名&#xff0c;直觉告诉我它不是一个普通的PPT模板合集&#xff0c;而是一套把PPT制作流程自动化的工具或框架。市面上叫“XX-master”的项目&#xff0c;通常意味着它承担了“主…

作者头像 李华
网站建设 2026/10/20 15:40:19

Postman实战:人脸识别API调试从环境配置到代码落地

1. 开搞之前&#xff0c;先摸清AI读脸接口的底细 1.1 你到底要调哪类接口 “AI读脸术”这个名字听着玄乎&#xff0c;实际落到工程上就是一套基于深度学习的人脸视觉接口。常见的有四类&#xff1a;人脸检测、人脸关键点定位、人脸属性分析、人脸比对与搜索。很多新手上来就急…

作者头像 李华
网站建设 2026/10/20 15:39:05

PyTorch实战笔记:从动态计算图到完整训练流程

如果你让我给准备入门的同学推荐第一个深度学习框架&#xff0c;我的答案永远是 PyTorch。原因很简单&#xff1a;它的调试体验最接近普通程序员写代码的感觉&#xff0c;模型是什么、数据在哪一步变了形&#xff0c;你随时可以用 print 看个明白。这篇文章是 PyTorch 的工程落…

作者头像 李华
网站建设 2026/10/20 15:38:42

diagram-design:面向工程协作的系统视觉建模方法论

1. “diagram-design”不是画图软件&#xff0c;而是一套系统性视觉表达方法论很多人第一次看到“diagram-design”这个词&#xff0c;下意识会联想到Visio、draw.io或者Figma里的“画流程图”功能——这恰恰是最大的认知偏差。它根本不是一个工具名称&#xff0c;也不是某个UI…

作者头像 李华