信号不是即发即处理:Linux信号阻塞与未决机制全解 上一篇文章把信号的产生方式过了一遍kill、raise、硬件异常、软件条件、还有键盘上的CtrlC。信号发出去了然后呢如果你写过稍微复杂点的Linux程序一定遇到过这个现象——按下CtrlC程序并没有立刻退出而是等到某个时刻才退出。这不是内核偷懒而是信号的处理本来就有一个“保存—阻塞—递达”的过程。这篇继续写进程信号系列的第二篇信号发出去之后进程内部到底怎么保存它怎么决定什么时候处理它以及被“挡住”的信号最终去了哪里。适合正在读APUE的读者、写嵌入式Linux应用的工程师还有排查“程序不响应CtrlC”这类奇怪问题的运维同学。1. 信号从产生到递达为什么不是“即发即处理”先想一个问题信号产生后进程为什么不立刻去执行对应的处理动作原因其实很朴素。信号处理函数是用户态的代码而进程在执行用户态代码时内核没法“凭空插入”一段函数去运行。内核能做的只是把信号记录在进程对应的数据结构里然后等一个合适的时机——进程因为系统调用、中断或异常而从用户态陷入内核态再准备返回用户态时——顺手检查一下有没有信号需要处理。这个检查点就像机场安检所有从内核态回到用户态的“旅客”都要过一遍。1.1 递达、未决、阻塞三个绕不开的概念这里必须先把三个术语定义清楚后面所有讨论都建立在它们之上信号递达Delivery信号真正执行处理动作的过程。无论处理动作是默认行为、忽略还是自定义handler只要“执行了”就算递达。信号未决Pending信号已经产生但还没被递达处于“等待处理”的状态。信号阻塞Block进程主动告诉内核“某个信号先别给我递达我还没准备好”。被阻塞的信号如果产生了就会一直停留在未决状态直到进程解除阻塞。三者的关系可以用一个生活场景来类比。你网购了一个快递快递员送到驿站信号产生驿站把快递存到货架上未决。你不想现在取就设置了“暂不派送”阻塞。等你哪天取消了这个限制解除阻塞快递才会真正送到你手上递达。注意驿站不会因为你不取货就把快递扔掉普通信号不丢也不会因为你设置了“暂不派送”就拒收快递员阻塞不影响产生。1.2 处理信号的真正时机从内核态回到用户态的路上为什么要强调“回到用户态的路上”这个时机因为这是理解信号处理机制最核心的一点。Linux里进程的代码运行分为用户态和内核态两个状态。用户态执行我们写的业务逻辑内核态执行系统调用、中断处理、进程调度等特权操作。当用户态代码发起read、write这类系统调用时CPU会切换到内核态去执行执行完毕准备返回用户态、恢复用户态指令流的前一刻内核会去检查当前线程的未决信号集合和阻塞集合。具体流程是这样的内核计算出“当前未决”且“没有被阻塞”的信号集合从这些信号里挑一个递达。如果该信号有自定义处理函数内核会修改用户态的栈帧和指令指针让CPU在返回用户态后跳转到handler的入口地址去执行。handler执行完再通过特殊的系统调用返回机制恢复到之前被中断的用户态代码继续执行。这也就解释了为什么信号处理函数里可以做很多“看起来像普通函数”的事但它本质上是在用户态栈上模拟了一次函数调用并不是内核起了个新线程去跑你的handler。所以处理函数里如果访问了不安全的全局数据照样会出并发问题这个后面踩坑部分再细说。2. 进程内部拿什么来“记”信号三张表和sigset_t理解了“信号不是即发即处理”之后你自然会问进程内部到底把信号记录在哪里这个问题的答案非常具体涉及Linux内核里task_struct结构体中的几个关键字段。虽然平时写应用代码不会直接操作task_struct但搞明白了这里你就知道sigprocmask、sigpending这些系统调用到底在操作什么。2.1 block、pending、handler各管什么每个进程严格说是每个线程后面多线程部分再区分在内核里都维护着与信号相关的三块核心信息内核字段保存的内容对应系统调用blocked信号屏蔽字表示哪些信号被阻塞sigprocmask / pthread_sigmaskpending未决信号集表示哪些信号已经产生但未递达sigpendingsighand信号处理动作包含每个信号对应的handler地址、标志位等signal / sigaction用快递驿站的类比来说pending就是驿站里的货架记录着哪些快递到了但还没给你派送blocked是你的“暂不派送”清单sighand则是你提前写好的“签收方式说明”——是放到快递柜、放到驿站、还是直接拒收。这里有一个容易误解的点blocked和pending都是sigset_t类型也就是“信号集”。系统为了把它们保存下来采用了位图bitmap的方式——每个信号对应一个bit位1表示存在/被阻塞0表示不存在/未阻塞。因为标准信号编号范围是1到31所以一个32位甚至更长的整数就能表示所有标准信号的集合状态。2.2 sigset_t就是一个位图但有严格的使用规矩sigset_t在用户空间的本质是一个足够大的整数数组每一位代表一个信号。比如第2个bit代表SIGINT第9个bit代表SIGKILL。但开发者不要试图直接读写它的内部字节因为不同体系结构下sigset_t的存储方式并不一致。正确做法是使用POSIX提供的一组操作函数sigemptyset(sigset_t *set)清空集合所有位都置0。sigfillset(sigset_t *set)全部置1。sigaddset(sigset_t *set, int signo)将指定信号对应位置1。sigdelset(sigset_t *set, int signo)将指定信号对应位置0。sigismember(const sigset_t *set, int signo)判断某个信号是否在集合中。我见过不少初学者直接sigset_t set; sigaddset(set, SIGINT);就完事了没有先执行sigemptyset初始化。这是典型的未定义行为因为栈上的sigset_t初始内容是不可预知的有可能某些位本来就是1导致后续阻塞了一堆莫名其妙的信号。正确写法永远是先清空、再添加。2.3 signal与sigaction注册处理函数时的差异sighand这块保存的是每个信号的处理动作。用户空间最常用的两个注册接口是signal()和sigaction()。早期的signal()实现语义不够统一不同Unix版本之间行为有差异。在Linux的glibc里signal()实际上是基于sigaction()封装的但默认情况下没有设置SA_RESTART标志这就导致一个典型问题如果进程在read()阻塞等待输入时收到一个不致命的信号read()可能被中断并返回EINTR错误而不是自动重启系统调用。用sigaction()注册时你可以在sa_flags里显式设置SA_RESTART让内核在信号处理完之后自动重启被打断的系统调用。另外sigaction()还允许你通过sa_mask指定一个信号集合这些信号在handler执行期间会被自动阻塞避免处理函数被同信号或其他信号重入打断。这是signal()给不了的精细控制。所以我现在写代码基本只用sigaction()signal()只在写一次性测试脚本时才凑合用一下。3. 阻塞信号后的行为不丢、合并、解除时算总账现在进入本文最核心的部分当你通过sigprocmask()把一个信号加入blocked集合后这个信号在进程内部会经历什么。3.1 阻塞不等于忽略先说一个最常见的误解。很多人以为“阻塞SIGINT”就等于“进程忽略SIGINT”这是不对的。忽略是处理动作的一种意思是信号即使递达了进程也不做任何事。阻塞则不同它只是把信号的递达时机往后拖信号本身仍然保存在pending未决集合里。一旦进程取消对该信号的阻塞这个信号立刻进入递达流程。一个很直观的验证方法阻塞SIGINT后给进程发送SIGINT然后用sigpending()查看未决集合你会看到SIGINT对应的bit位是1。这说明信号并没有消失它正在那里等着呢。3.2 普通信号未决期间只记录一次这是普通信号1到31号标准信号和实时信号32到64号最关键的差异之一。普通信号的未决集合用的是位图一个信号只有一个bit位。所以当SIGINT已经被置为未决状态你又连续发送了5次SIGINTpending集合里SIGINT那一bit仍然只是1并不会叠加成6。等解除阻塞后handler只会执行一次。也就是说阻塞期间的多次同类信号会被合并。实时信号则不一样它们有独立的队列机制。同一个实时信号可以多次排队每发送一次就多一个未决实例解除阻塞后会一个一个递达。关于实时信号后面会单独展开。3.3 解除阻塞的瞬间默认动作也会“算账”这一点非常反直觉值得单独强调。很多人写这样的代码先阻塞SIGTERM觉得“这下进程不会被杀掉了”等自己处理完关键数据后再解除阻塞。但如果你没有为SIGTERM注册自定义handlerSIGTERM的默认动作是终止进程。那么在解除阻塞的那一刻pending里躺着的SIGTERM立刻递达默认动作生效进程直接退出你后面想执行的“解除阻塞之后继续做xx”的代码可能一行都跑不到。这种情况在守护进程、定时任务程序里特别容易踩坑。你以为“阻塞了就安全了”实际上只是把终止命令延迟到了你解除阻塞的那一刻。正确做法是如果某个信号真的不想让进程退出要么用sigaction()给该信号注册一个handler哪怕handler是空函数也行要么在解除阻塞前想清楚后果。3.4 sa_mask处理函数执行期间的那些隐藏阻塞再聊一个sigaction()里容易被忽略的细节sa_mask字段。假设你给SIGINT注册了handlerhandler里执行了比较耗时的操作。现在handler还没执行完又来了一次SIGINT会发生什么默认情况下进程在处理某个信号时会自动把“当前正在处理的这个信号”加入阻塞集合。也就是说SIGINT的handler执行期间新的SIGINT会被阻塞并进入pending状态等当前handler返回后再一次递达。这是内核为了防止同一个处理函数被重入而设计的保护机制。如果你确实希望处理函数能被同信号打断可以在sa_flags里设置SA_NODEFER一般不推荐。sa_mask则是在这个自动阻塞的基础上额外指定一些信号在处理期间也要被阻塞。举个例子假设你在SIGINT的handler里会操作一个全局链表而SIGTERM的handler也会操作这个链表为了防止两者交叉执行导致链表崩溃你就可以在sa_mask里加上SIGTERM这样SIGINT handler执行期间SIGTERM不会被递达。4. 三个小实验亲手观察信号的保存与阻塞理论讲了这么多不如直接跑代码。这里给出三个小实验覆盖阻塞、未决、解除阻塞、以及处理期间的自动阻塞。都是在普通Linux终端上可以直接编译运行的C程序。4.1 实验一阻塞SIGINT看一眼pending位图先写一个最简单的实验阻塞SIGINT给自己发送SIGINT然后查看未决信号集最后解除阻塞观察handler执行。#include stdio.h #include signal.h #include unistd.h #include stdlib.h static void handler(int sig) { printf(handler: caught signal %d\n, sig); } static void show_pending(const char *tag) { sigset_t pending; sigemptyset(pending); if (sigpending(pending) ! 0) { perror(sigpending); return; } printf(%s pending:, tag); for (int sig 1; sig 31; sig) { if (sigismember(pending, sig)) { printf( %d, sig); } } printf(\n); } int main(void) { sigset_t block_set, old_set; if (signal(SIGINT, handler) SIG_ERR) { perror(signal); exit(1); } sigemptyset(block_set); sigaddset(block_set, SIGINT); if (sigprocmask(SIG_BLOCK, block_set, old_set) ! 0) { perror(sigprocmask); exit(1); } printf(SIGINT blocked\n); kill(getpid(), SIGINT); printf(sent SIGINT\n); show_pending(after send); if (sigprocmask(SIG_SETMASK, old_set, NULL) ! 0) { perror(sigprocmask); exit(1); } printf(SIGINT unblocked\n); return 0; }保存为sigblock.c编译运行gcc -o sigblock sigblock.c ./sigblock输出结果如下SIGINT blocked sent SIGINT after send pending: 2 handler: caught signal 2 SIGINT unblocked注意几点pending: 2说明SIGINT2号信号确实被记录在未决集合里了。2这个数字就是信号编号。handler: caught signal 2出现在SIGINT unblocked之前说明解除阻塞的操作sigprocmask(SIG_SETMASK, ...)在返回用户态前就触发了信号递达handler在解除阻塞的调用栈里就被执行了。这个实验直观展示了SIG_BLOCK、SIG_SETMASK、sigpending三个接口的配合使用。4.2 实验二连续发送多个同信号验证合并在实验一的基础上把“发送一次SIGINT”改成“连续发送5次SIGINT”看看结果有什么变化。在main函数里替换成if (sigprocmask(SIG_BLOCK, block_set, old_set) ! 0) { perror(sigprocmask); exit(1); } printf(SIGINT blocked\n); for (int i 0; i 5; i) { kill(getpid(), SIGINT); printf(sent SIGINT #%d\n, i 1); show_pending(after send); }重新编译运行你会看到每次show_pending的输出都是pending: 2不会出现“2 2 2”这种叠加也不会变成3、4、5。等解除阻塞后handler只执行一次。这是普通信号位图语义的直观验证。如果你手里有多个终端甚至可以开两个终端一个运行这个程序阻塞阶段sleep几十秒另一个对进程反复发送kill -INT pid。你会发现无论发送多少次解除阻塞后进程最多只响应一次SIGINT。这就是“相同普通信号不排队”的实际表现。4.3 实验三sa_mask如何影响信号递达最后看一个稍微进阶的实验验证sigaction()的sa_mask在handler执行期间确实阻塞了指定信号。#include stdio.h #include signal.h #include unistd.h #include stdlib.h static void show_pending(const char *tag) { sigset_t pending; sigemptyset(pending); if (sigpending(pending) ! 0) { perror(sigpending); return; } printf(%s pending:, tag); for (int sig 1; sig 31; sig) { if (sigismember(pending, sig)) { printf( %d, sig); } } printf(\n); } static void handler_usr1(int sig) { printf(handler_usr1: catch %d\n, sig); } static void handler_int(int sig) { printf(handler_int enter\n); kill(getpid(), SIGUSR1); show_pending(inside handler_int); printf(handler_int exit\n); } int main(void) { struct sigaction act; /* SIGINT的handler执行期间阻塞SIGUSR1 */ act.sa_handler handler_int; sigemptyset(act.sa_mask); sigaddset(act.sa_mask, SIGUSR1); act.sa_flags 0; if (sigaction(SIGINT, act, NULL) ! 0) { perror(sigaction); exit(1); } signal(SIGUSR1, handler_usr1); kill(getpid(), SIGINT); sleep(1); return 0; }编译运行gcc -o sigmask sigmask.c ./sigmask输出handler_int enter inside handler_int pending: 10 handler_int exit handler_usr1: catch 10分析SIGUSR1的编号是10所以在handler_int内部pending里出现了10。因为在sa_mask里设置了SIGUSR1所以handler_int执行期间SIGUSR1即使被发送了也只能待在pending里不会立刻递达。handler_int退出后SIGUSR1的阻塞被解除于是handler_usr1才被执行。这说明sa_mask确实能控制“某段handler执行期间哪些信号不允许打扰”。5. 多线程、实时信号和排查经验最后这部分是实战中最容易碰到的问题。很多信号相关的Bug并不是不懂API而是没有理解信号在多线程、实时信号等场景下的特殊语义。5.1 多线程下屏蔽字线程独立处理函数全体共享多线程程序里信号的处理比单线程复杂得多。关键要掌握三点第一每个线程有自己的blocked集合和pending集合。线程通过pthread_sigmask()设置自己的阻塞集合不要用sigprocmask()。POSIX标准对多线程下调用sigprocmask()的行为没有明确定义虽然在Linux上它实际影响的是调用线程但为了可移植性和代码可读性多线程一律用pthread_sigmask()。第二进程内的所有线程共享信号处理函数。也就是说你给SIGINT注册的handler任何一个线程收到SIGINT时都会执行这个handler不存在“这个线程单独注册一个handler”的说法。第三用kill()向进程发送信号时内核会从进程的所有线程中挑选一个“不阻塞该信号”的线程去递达。如果所有线程都阻塞了该信号那信号就留在进程级pending里。直到某个线程解除阻塞信号才会递达。这个机制常常导致一些难以排查的问题比如你用kill -TERM pid去停一个多线程服务结果服务没反应其实就是所有线程都把SIGTERM阻塞了。5.2 实时信号排队和携带数据的差异之前提到标准普通信号不排队多个同信号会被合并。但实时信号SIGRTMIN到SIGRTMAX提供了完全不同的语义实时信号支持排队。同一个实时信号可以发送多次每次都会有一个独立的未决实例。实时信号可以携带数据。通过sigqueue()发送时可以在union sigval里传递一个整数或指针接收方在siginfo_t结构里通过si_value取出来。这个特性在需要传递具体数据的场景下非常有用。比如某个工作线程完成后你想告知主线程“任务编号是42”用普通信号做不到用sigqueue(SIGRTMIN, 42)就很容易。但要注意实时信号也有队列长度限制/proc/sys/kernel/rtsig-max可以查看上限默认值通常是很大的但单位时间内塞入过多实时信号仍然可能丢。5.3 程序不响应CtrlC从这几点开始排查如果你在生产环境里遇到“程序按CtrlC没反应”的问题我建议按下面的顺序排查第一确认进程是不是压根没收到信号。用kill -INT pid手动发一次如果手动发也没反应说明进程内部大概率阻塞了SIGINT。这时候可以查看/proc/pid/status里的SigBlk字段它以十六进制显示当前进程的阻塞信号集。比如SigBlk: 0000000000000004表示第2位SIGINT被阻塞了。第二检查是不是handler里出了问题。如果SigBlk没有阻塞SIGINT那可能是handler执行后没正常返回比如在handler里死循环、死锁或者调用了非异步信号安全的函数导致卡住。这种情况可以用gdb attach到进程上执行bt看当前栈在哪里。第三检查是不是所有线程都阻塞了该信号。多线程服务经常在初始化时统一调用pthread_sigmask阻塞一堆信号如果某个信号是进程级别的终止信号这样会直接把信号挡在门外。/proc/pid/status里的SigBlk展示的是主线程的阻塞集合其他线程要看/proc/pid/task/tid/status。第四顺手提一下sigsuspend()的典型用法。如果你想“原子地修改阻塞集合并且等待信号”不要先用sigprocmask()解除阻塞再调用pause()因为这两步之间有一个窗口期信号可能在这期间到达并被错误丢弃。正确做法是用sigsuspend()它把“设置新阻塞集合等待信号恢复原阻塞集合”合并成一个原子操作。这是很多网络服务实现信号等待时的标准姿势。另外还有一条处理函数编写经验在handler里尽量不要调用printf()这类非异步信号安全函数。实验代码里我用printf只是为了演示方便真实项目里建议用write()向管道写入一个字节作为通知或者仅仅设置一个volatile sig_atomic_t标志位主循环里再检查这个标志。这样做既能避免数据竞争又能避免handler执行时间过长打乱主流程。我在一个嵌入式项目里就遇到过handler里直接调用了某个库的日志函数结果日志函数内部有锁而主线程刚好持锁信号处理时直接死锁。排查了半天最后把所有handler都改成只管置位标志位问题彻底消失。关于信号的保存与阻塞机制有句老话总结得很到位信号处理的核心不是“如何处理”而是“为什么此刻处理、为什么此刻不处理”。把pending、blocked、handler这三张表和它们之间的交互关系刻在脑子里遇到大部分信号相关的疑难杂症你基本都能很快定位到原因。

相关新闻

最新新闻

2026年AI搜索优化服务商怎么选?5项能力核验与候选建议

2026年AI搜索优化服务商怎么选?5项能力核验与候选建议

摘要:做AI搜索优化,不应只看文章产量或某一次回答是否出现品牌,而要核验品牌知识、跨平台监测、归因分析、内容执行和发布后复测能否形成闭环。本文从技术与交付角度拆解5项选型能力,并重点说明AI Native SaaS与“传统软件增加AI写…

2026/9/9 15:21:59
华为OD机试“新词挖掘”题解:滑动窗口与最小覆盖子串的实战剖析

华为OD机试“新词挖掘”题解:滑动窗口与最小覆盖子串的实战剖析

我最早看到“新词挖掘”这道题,是在整理华为OD机试真题题库的时候。当时第一反应是——这不就是LeetCode 76题“最小覆盖子串”吗?但仔细读完题以后发现,出题人给这道经典滑动窗口题重新披了一层“自然语言处理”的外衣,还顺势改了…

2026/9/9 15:21:59
杭州二手 MacBook 上门回收|笔记本一体机显示器专业回收

杭州二手 MacBook 上门回收|笔记本一体机显示器专业回收

家里闲置的二手MacBook想出手,顺带还有旧笔记本、一体机、显示器堆着落灰,不少杭州用户都遇到过这些麻烦:普通回收商不懂苹果设备行情,压价狠、报价乱;想打包处理闲置数码,却要分开找好几家商家&#xff1b…

2026/9/9 15:21:59
数学建模论文复现全攻略:9种实用技巧与10款AI写作工具

数学建模论文复现全攻略:9种实用技巧与10款AI写作工具

打开搜索框,输入"复现"两个字,你会看到完全两种画风的内容:一边是漏洞复现、BEVFusion复现、ORB-SLAM3复现这类偏工程和安全的词条,另一边是"2024年国赛A题板凳龙全流程建模解析""华为杯研究生数学建模优…

2026/9/9 15:21:59
分层笔记法:从课堂速记到个人知识库的高效实践

分层笔记法:从课堂速记到个人知识库的高效实践

1. 内容整体设计与思路拆解先说个真实场景。4月1号那天,我坐在教室里,翻开笔记本,突然意识到一个很尴尬的事实:上学期记的笔记,现在翻出来看,有一大半已经看不懂了。不是字迹潦草那种看不懂,而是…

2026/9/9 15:21:59
2026年实测这3个学生党必备的降AI率平台,毕业论文AIGC检测稳稳压到10%以下!

2026年实测这3个学生党必备的降AI率平台,毕业论文AIGC检测稳稳压到10%以下!

最近辅导学弟学妹写论文,发现一个明显的变化:大家不再只担心查重率,反而对AIGC检测更焦虑了。导师一句“AI痕迹太重”,可能直接导致整篇论文被要求重写。现在知网、维普的AI检测率红线卡在10%,一旦超标就存在风险。市面…

2026/9/9 15:16:58