Linux信号捕捉详解:从signal到sigaction的工程实践
工试云启 考证服务中心整理

1. 信号的本质先理解进程的“生命节拍”1.1 信号不是中断而是一种异步通知很多朋友学Linux信号时会习惯性地把信号和中断划上等号这个类比能帮你入门但很容易带偏你的理解。中断是硬件的“强制插队”由CPU和内核协同完成现场保护与恢复而信号本质上是一种软件层面的异步通知机制是内核或另一个进程向目标进程发送的一段“消息”内容只有一个整数编号含义由内核和进程共同约定。进程在收到信号之前完全不知道信号什么时候会来信号来了之后进程也不会被打断去执行“信号对应代码”它只是在某个确定的时机从内核态返回用户态之前被要求“暂停手头的活儿先处理一下这个事件”。这也是为什么我们常说信号是“软中断”因为它在形式上模拟了中断的异步性但处理路径完全不同——信号不涉及硬件级的现场保护和特权级切换。我用一句话总结信号是内核递给进程的一张小纸条纸上写的不是数据而是一个“该做点什么”的指令编号。作为系统编程人员你的任务就是替进程想清楚收到这张纸条时到底该做什么、不能做什么、做完了如何回到原来的节奏。1.2 信号的一生从产生到处置的完整路径一个信号从“出生”到“被处理”大致要经过下面这几个阶段产生Generation信号可以由硬件异常产生比如除零触发SIGFPE、可以由内核产生比如进程访问非法内存触发SIGSEGV、也可以由显式调用产生比如kill()、raise()、alarm()甚至终端按键CtrlC也会产生SIGINT。递送Delivery内核把信号登记到目标进程的信号挂起队列中。这里有个关键概念进程的每个信号在“未处理”时只会记录一个布尔标记并不做排队。换句话说同一个信号即使被发了100次只要没有被处理内核只记“你有这个信号”处理时也只当作一次处理。处置Disposition进程对该信号采取的执行动作。这个过程不由进程主动调用代码来完成而是由内核在恰当时机进程从内核态返回用户态前统一调度的。处置动作有且只有三种执行默认动作、忽略、捕捉即调用用户自定义的信号处理函数。进程可以通过signal()或sigaction()告诉内核“这个信号我要自定义处理”这个过程就是标题里说的“信号捕捉”。1.3 信号的“默认动作”先记住这些“交规”不去自定义时信号会执行默认动作常见的有这么几类信号默认动作典型触发场景SIGINT终止进程终端按下CtrlCSIGTERM终止进程kill pid默认信号SIGKILL强制终止不可捕捉不可忽略kill -9SIGSEGV终止进程并可产生core dump野指针访问非法地址SIGCHLD忽略子进程停止/结束时发给父进程子进程退出时SIGPIPE终止进程向已关闭的管道或socket写数据SIGALRM终止进程alarm()超时到达这里面最特殊的就是SIGKILL和SIGSTOP这两个信号内核不允许进程捕捉或忽略。原因很朴素如果系统里的每个进程都能无视“杀死”指令那管理员就没法管理机器了。系统必须在任何情况下都保留一个“最终手段”。这个限制也提示我们真正要保障的优雅退出应该靠SIGTERM或SIGINT来实现而不是挣扎地想去拦截SIGKILL。2. signal与sigaction两代信号捕捉接口的攻守道2.1 signal函数为什么背着“历史包袱”早期signal()接口非常简单注册一个处理函数就完事#include signal.h void handler(int signo) { // 处理逻辑 } signal(SIGINT, handler);但问题在于不同Unix分支对signal()的语义不统一。有的系统过去的System V在信号处理函数执行期间自动将该信号重置为默认动作这意味着如果同一个信号在第一次处理还没结束时再次到来进程会直接按默认动作退出。还有的系统在信号处理期间不会阻塞后续同类信号导致处理函数重入状态混乱。像我早年在一款嵌入式设备上调试时就遇到过诡异现象程序偶尔会在连续两次快速点击某个按键时闪退。排查到最后发现就是signal(SIGINT, handler)在处理函数执行期间信号被重置第二次按键信号来时默认动作直接终止了进程。所以现代Linux开发我几乎不用signal()一律用sigaction()。signal()的语义差异已经不是“细节问题”而是“历史遗留的坑”。2.2 sigaction把控制力握在自己手里sigaction()提供了完整的结构体控制通过字段精确指定信号递送期间的行为#include signal.h struct sigaction act; act.sa_handler my_handler; // 处理函数 sigemptyset(act.sa_mask); // 处理期间阻塞的信号集先清空 act.sa_flags 0; // 标志位稍后详解 sigaction(SIGINT, act, NULL);这里面的sa_mask表示当进程正在执行处理函数时额外需要阻塞哪些信号。比如你希望在处理SIGINT的过程中暂时无视SIGTERM那么就把SIGTERM加进sa_mask。这个字段不是“全局屏蔽”而是“处理期间的附加屏蔽”函数执行完会自动恢复原状不需要你手动解除。sa_flags常用的几面旗子SA_RESTART让被信号打断的系统调用自动重新启动避免返回EINTR后面会详细讲。SA_NOCLDWAIT配合SIGCHLD使用子进程退出时自动回收不产生僵尸进程。SA_SIGINFO改用sa_sigaction字段处理函数可以获取发信号进程的PID、UID以及信号来源等详细信息。sa_sigaction是sa_handler的增强版函数签名多两个参数void handler(int signo, siginfo_t *info, void *context);context参数指向ucontext_t结构里面保存着信号打断时的进程上下文适合做高级调试或用户态上下文切换日常开发很少碰它。2.3 实战对比用两代接口写同一个SIGINT处理下面用两段代码演示差异。第一段用老接口#include stdio.h #include signal.h #include unistd.h volatile sig_atomic_t flag 0; void old_handler(int signo) { flag 1; } int main(void) { signal(SIGINT, old_handler); while (!flag) { pause(); } printf(received SIGINT, exiting\n); return 0; }第二段用新接口加上了SA_RESTART和屏蔽SIGTERM#include stdio.h #include signal.h #include unistd.h volatile sig_atomic_t flag 0; void new_handler(int signo) { flag 1; } int main(void) { struct sigaction act; act.sa_handler new_handler; sigemptyset(act.sa_mask); sigaddset(act.sa_mask, SIGTERM); // 处理SIGINT期间屏蔽SIGTERM act.sa_flags SA_RESTART; sigaction(SIGINT, act, NULL); while (!flag) { pause(); } printf(received SIGINT, exiting\n); return 0; }从表面上看两段代码差别不大但第二段的控制粒度完全不同它能明确告诉内核“我在处理SIGINT期间不响应SIGTERM”还能让被信号打断的pause()或read()自动恢复。老接口做不到这些甚至第一段代码在某些Unix系统上还会因为信号重置出现“只触发一次”的尴尬局面。3. 信号集与信号屏蔽让信号学会“排队等待”3.1 信号集的基本操作信号集sigset_t本质上是一个位图。内核用__NSIG个比特位记录哪些信号在集合里。我们用一组固定函数操作它sigemptyset(set); // 清空集合所有位为0 sigfillset(set); // 补满集合所有位为1 sigaddset(set, SIGINT); // 把SIGINT加入集合 sigdelset(set, SIGINT); // 把SIGINT移出集合 sigismember(set, SIGINT); // 判断SIGINT是否在集合中有个新手常犯的错误只用memset(set, 0, sizeof(set))来“清空”信号集。这在Linux上碰巧能工作但代码并不可移植——不同系统的sigset_t内部布局不同甚至可能有填充比特。正确的做法永远是先sigemptyset()再sigaddset()这是API的基本礼节。3.2 屏蔽字的生效机制内核视角每个进程的内核数据结构里都有一个blocked字段这就是信号屏蔽字。内核在判断“是否要把信号递送给进程”时逻辑大致是这样的信号产生后记录在进程的pending位图中。在进程即将返回用户态时比较pending ~blocked如果结果非零说明有“未被屏蔽”的信号那就选编号最小的那个去递送。如果结果为零说明信号虽然产生了但被屏蔽着那就继续挂起等待解除屏蔽。这就解释了为什么被屏蔽的信号不是“被丢弃”而是“排队在pending位图里”。一个信号只要没有实际递送多个相同信号也只算一个。这也是后期设计高可靠服务时最需要注意的点丢失信号的业务影响有多大架构上必须自己想清楚。3.3 关键函数sigprocmask与pause实战sigprocmask()用于读取或修改当前进程的屏蔽字。常见用法分为三步先构建设置新屏蔽字的集合再调用sigprocmask(SIG_SETMASK, newset, oldset)切换最后在必要时用SIG_SETMASK恢复。pause()是非常简单的“挂起等待”机制进程进入睡眠直到任意一个未屏蔽的信号递送并处理完成。很多人第一次用它写信号等待时会陷入一个“几乎必现”的竞态// 错误示范 sigprocmask(SIG_BLOCK, set, NULL); // 先屏蔽SIGUSR1 if (flag 0) { pause(); // 等待信号 } sigprocmask(SIG_UNBLOCK, set, NULL); // 解除屏蔽问题在于如果在pause()之前SIGUSR1已经产生了并且处理函数里把flag置1了那么pause()会一直睡下去永远不会被唤醒。信号在“屏蔽期”来临处理完了然后你才去等待——这不叫同步这叫错过。3.4 原子操作与信号竞态为什么推荐sigsuspend解决上面竞态的标准姿势是sigsuspend()。它把“临时修改屏蔽字 挂起等待 恢复原屏蔽字”这三件事合并成一个原子操作中间不可能被信号插队打乱sigset_t set, oldset; sigemptyset(set); sigaddset(set, SIGUSR1); sigprocmask(SIG_BLOCK, set, oldset); // 先屏蔽 if (flag 0) { sigsuspend(oldset); // 原子地解除屏蔽并睡眠等SIGUSR1 } sigprocmask(SIG_SETMASK, oldset, NULL); // 恢复sigsuspend()执行后进程的屏蔽字被临时替换为oldset即不再屏蔽SIGUSR1同时进程进入睡眠。内核在递送信号——执行处理函数——恢复上下文——返回sigsuspend()的调用现场后再自动把屏蔽字恢复为调用前的样子。整个过程一气呵成不存在“信号来了但进程还在等”的空窗。我在实际项目里排查信号同步问题时有一个经验凡是看到“先解除屏蔽、再等待”或“先判断、再等待”这种分离操作就要高度怀疑存在竞态。信号安全编程的核心思想就是“判断和等待必须是原子的”。4. 信号捕捉实战守护进程里的应用场景4.1 场景设计一个可平滑退出的服务信号捕捉不是学术概念它落到真实项目里最常见的需求就是让服务优雅退出。设想一个正在处理任务的后台进程管理员执行kill pid默认SIGTERM。如果进程什么都不做默认动作是立即终止正在进行的关键业务可能会写坏文件、丢失状态。我们希望进程能收到SIGTERM后停止接收新任务把当前任务干净收尾再释放资源退出。设计要点主循环不阻塞式等待信号而是通过一个volatile sig_atomic_t标志通知。信号处理函数只做“置标志”这一件极小的事所有重活交给主循环去处理。主循环周期性检查标志位一旦发现“需要退出”就按顺序做清理工作。4.2 代码实现信号处理与主循环配合#include stdio.h #include signal.h #include unistd.h #include stdlib.h volatile sig_atomic_t g_stop 0; static void stop_handler(int signo) { g_stop 1; // 只做标记不做清理 } int main(void) { struct sigaction sa; sa.sa_handler stop_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; sigaction(SIGTERM, sa, NULL); sigaction(SIGINT, sa, NULL); printf(service started, pid%d\n, getpid()); while (!g_stop) { // 模拟处理业务 printf(working...\n); sleep(2); } printf(shutdown sequence: saving state, releasing resources\n); // 真正的清理动作放在这里 printf(service exited gracefully\n); return 0; }这段代码简单但背后有一个重要原则信号处理函数里尽量避免调用非异步信号安全的函数。像printf、malloc、open、fprintf这类函数都不是异步信号安全的。为什么因为信号可能恰好在你执行malloc的过程中打断它而malloc内部的全局状态正处于半更新状态处理函数里再调用malloc就会造成不可预期的崩溃或死锁。这就是为什么上面的处理函数里只用g_stop 1。sig_atomic_t是C标准保证的、读写是原子的整数类型适合在信号处理函数和普通代码之间传递标志。4.3 SIGCHLD与非阻塞wait子进程回收的艺术父进程最头疼的问题之一就是僵尸进程。子进程退出时内核只保留一个很小的task_struct给父进程来查询退出状态如果父进程不调用wait()或waitpid()回收这个“已死但未收尸”的进程就占着进程表项不释放。服务器长期跑下来子进程频繁创建销毁僵尸进程堆积多了系统性能会明显恶化。SIGCHLD的作用就是子进程停止或退出时内核自动给父进程发一个SIGCHLD信号。父进程可以注册处理函数来回收子进程#include stdio.h #include signal.h #include sys/wait.h #include unistd.h static void child_handler(int signo) { int status; pid_t pid; // 循环回收防止多个子进程同时退出只处理一个 while ((pid waitpid(-1, status, WNOHANG)) 0) { printf(reaped child %d\n, pid); } } int main(void) { struct sigaction sa; sa.sa_handler child_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART | SA_NOCLDWAIT; // 注意如果用了SA_NOCLDWAIT内核会自动回收处理函数里waitpid会直接失败 sigaction(SIGCHLD, sa, NULL); for (int i 0; i 3; i) { pid_t pid fork(); if (pid 0) { _exit(0); // 子进程立即退出 } } sleep(5); return 0; }这里有两个细节值得讲。第一处理函数里要用waitpid(-1, status, WNOHANG)循环拉取因为SIGCHLD信号在挂起期间只能合并成一次但可能同时有多个子进程退出一次waitpid只回收一个必须循环到没有子进程可收为止。第二SA_NOCLDWAIT标志告诉内核“子进程退出后别给我留着直接扔掉”这时父进程不再需要调用waitpid。如果你既设置了SA_NOCLDWAIT又在处理函数里waitpid那多半会拿不到PID。我踩过的一个坑是在处理函数里调用printf打印回收日志。信号来临时如果正好某个printf在另一个上下文中执行到一半处理函数里的printf会让同一个FILE*缓冲区出现交叉写入轻则日志乱码重则死锁。正确做法是处理函数里只把pid记录到一个数组或管道由主循环去打印。严谨性就在这种细节里。4.4 信号与计时alarm实现超时控制有时候进程需要实现“一段时间内等不到结果就放弃”的超时逻辑alarm()配合SIGALRM是经典方案#include stdio.h #include signal.h #include unistd.h static void timeout_handler(int signo) { // 超时处理 } int main(void) { struct sigaction sa; sa.sa_handler timeout_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; sigaction(SIGALRM, sa, NULL); alarm(5); // 5秒后触发SIGALRM printf(waiting for input...\n); char buf[100]; ssize_t n read(STDIN_FILENO, buf, sizeof(buf)); if (n 0) { // 检查errno } else if (n 0) { printf(EOF\n); } else { printf(got %zd bytes\n, n); } return 0; }alarm()使用起来很直观但要注意两件事一是每个进程同一时刻只能有一个alarm定时器重复调用会覆盖前一个二是alarm()返回的是“上一次定时器剩余秒数”如果返回0说明没有定时器或者定时器刚触发。对于需要精确到毫秒级的超时应改用setitimer()或POSIX定时器timer_create()搭配sigev_notify机制后者还能选择用信号还是线程回调来通知超时事件。很多人会问如果read()被SIGALRM打断程序会怎样答案取决于你是否设置了SA_RESTART。这个细节很小影响却很大我们接下来集中说。5. 常见问题与排查实录5.1 为什么我的信号处理函数“不执行”这类问题排名第一的原因通常不是信号没发出去而是进程“根本没机会处理”。举个例子如果代码用sigprocmask(SIG_BLOCK, set, NULL)屏蔽了信号却忘记解除屏蔽那么信号会一直挂在pending位图里进程永远不会去执行处理函数。这种“看不见”的错误最坑人因为kill命令看着信号发出去了进程确实也收到了但就是不执行。排查时可以用/proc/pid/status查看进程的屏蔽字和挂起信号grep -E SigBlk|SigPnd|SigIgn|SigCgt /proc/pid/statusSigBlk表示当前屏蔽的信号位图SigPnd表示挂起的信号位图。如果在SigPnd里看到了你发的信号而进程就是不处理那就是屏蔽字的问题。另一种常见情况处理函数注册在fork()之后。子进程会继承父进程的信号处理配置但如果在fork()之后、exec()之前切换了新程序新程序会恢复所有信号处理函数为默认动作只有被忽略的信号SIG_IGN会被保持忽略。所以做守护进程的时候“哪些信号要忽略、哪些要捕捉”这件事必须在exec之前想清楚。5.2 信号处理函数里的printf为什么乱输出这个问题的根因前面已经埋下伏笔printf不是异步信号安全的。它内部维护了用户态缓冲区还依赖于FILE*的锁。如果信号打断了主流程正在进行的printf处理函数里再调printf就会发生两个上下文的缓冲区交错写入。轻则输出顺序错乱重则死锁——因为FILE*锁在被打断时可能已经处于锁定状态处理函数里去获取同一把锁就永远等下去。我给团队定的一个硬规矩是处理函数里只做赋值、置位、signal、kill、write这些异步信号安全操作其他一律不做。如果非要打印信息用write(STDERR_FILENO, ...)直接走系统调用绕过用户态缓冲。或者更优雅的方式处理函数里把信息写入管道pipe主循环通过select/epoll读取再有条不紊地打印、落盘。5.3 信号会打断阻塞的系统调用吗EINTR在早期Unix里信号递送会打断阻塞的系统调用比如read()正在等待终端输入SIGINT一进来read()立即返回-1errno设为EINTR。如果你没处理程序会误以为读取失败而退出或重试异常。现代Linux的处理方式取决于sigaction的SA_RESTART标志系统调用类别设置SA_RESTART不设置SA_RESTARTread/write慢速设备自动重启动返回EINTRwait/waitpid自动重启动返回EINTRnanosleep不会自动重启返回EINTRpoll/select/epoll_wait部分可重启建议手动处理返回EINTR最实用的经验是别指望SA_RESTART一劳永逸。凡涉及时序敏感的逻辑比如nanosleep、poll、epoll_wait都要在返回EINTR后按业务目标决定是否继续循环等待。例如用nanosleep做秒级延时SA_RESTART救不了你必须手动循环重算剩余时间。排查这类问题时strace是你的好帮手strace -f -e traceread,poll,nanosleep ./your_program一旦看到read(...) ? ERESTARTSYS (To be restarted)之类的输出就知道内核在告诉你“旧系统调用被打断可重启性取决于你的标志位”。这时返回代码里的EINTR就是那条线索。5.4 信号安全速查表场景推荐做法不推荐做法信号处理函数内通知主流程置volatile sig_atomic_t标志调用printf、malloc主循环等信号sigsuspend原子等待先解除屏蔽再pause回收子进程waitpidWNOHANG循环或SA_NOCLDWAIT只在处理函数wait一次被信号打断的系统调用设置SA_RESTART对时序敏感调用手动处理EINTR无条件忽略EINTR继续原样重试守护进程信号配置在exec前统一设置明确忽略和捕捉清单依赖默认行为“到时候再说”写在最后的一点个人体会信号这套机制看似API不多但真正用好的门槛在于“善于把自己想象成内核”。我在项目中碰到的多数信号bug都不是因为不会调sigaction而是因为对递送时机、屏蔽合并、竞态窗口这些底层行为理解不够。建议你拿到一个信号场景时先问三个问题信号什么时候可能来来了之后处理函数会被什么打断我的主逻辑和信号之间有没有“判断-等待”分离的窗口想明白这三个信号相关的代码基本不会有太大的坑。最后分享一个小技巧调试信号处理逻辑时可以在处理函数里首行加入write(STDERR_FILENO, caught\n, 7)来快速确认信号是否真的抵达。这个输出不会受到缓冲区和锁的干扰排查问题能省下不少力气。等你把信号这套机制真正吃透再回头去看守护进程、并发服务乃至内核模块的设计会发现很多纠缠不清的“诡异现象”其实都有清晰的答案。