ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux 1-31号普通信号全解:默认动作、捕获阻塞与生产避坑

Linux 1-31号普通信号全解:默认动作、捕获阻塞与生产避坑 有一次线上事故让我记到现在一个跑在容器里的订单服务内存持续上涨运维同学连日志都没看直接kill -9进程是干净利落地没了但内存里没来得及落盘的几百条订单也跟着蒸发。事后复盘问题不在于该不该重启而在于我们对 Linux 信号的理解只停留在kill -9能杀进程这一层。Linux 里数字编号 1 到 31 的这一批信号也就是常说的普通信号标准信号每一个的默认动作、能不能被捕获、能不能被阻塞都是写死的规则踩错一个就是数据丢失或者服务假死。这篇就把这 31 个信号从头到尾拆开讲包括它们的编号含义、默认行为、实际生产里怎么用以及我自己踩过的几个坑。1. 一次数据丢失事故引出的问题信号不是关机键1.1 信号在 Linux 里到底扮演什么角色很多人第一次接触信号是从CtrlC开始的。按下去前台进程就停了于是脑子里形成一个印象信号就是让进程停下来的指令。这个理解只对了一小半。信号本质上是内核向进程投递的一种异步通知它传递的信息量极小只有一个整数编号外加一点点附加信息比如siginfo_t里的发送者 PID、错误码。它不负责传数据只负责提醒有事发生。这个设计决定了信号的两个特性。一是异步进程在任意一条指令执行到一半的时候都可能收到信号处理函数会在不确定的时刻插入执行。二是轻量正因为不承载数据内核可以用很低的成本在进程间、内核与进程间传递它。代价就是信号没有队列语义连续发多个相同信号接收方可能只感知到一次。提示信号和中断经常被混为一谈。中断是硬件或内核层面的机制信号是进程层面的抽象。中断处理完会返回原指令信号处理完会返回原代码的下一条指令而且信号处理是在用户态执行的。1.2 一个信号从产生到被处理中间走了哪几步信号的生命周期可以粗暴地分成三段产生、递送、处理。产生的方式有好几种最常见的几种是用户按了终端组合键比如CtrlC触发SIGINTCtrl\触发SIGQUITCtrlZ触发SIGTSTP调用了系统调用比如kill(2)、raise(3)、alarm(2)、setitimer(2)硬件异常被内核翻译成信号比如除零触发SIGFPE、非法内存访问触发SIGSEGV内核或终端驱动在特定事件发生时主动发送比如子进程退出触发SIGCHLD、终端窗口大小改变触发SIGWINCH。信号产生后并不会立刻进入处理函数内核先把它放进目标进程的未决集合pending set。如果这个信号此刻没有被阻塞内核就在进程从内核态返回用户态的那个瞬间把它递送出去先保存当前上下文跳转到处理函数处理完再通过sigreturn恢复。如果被阻塞了信号就一直挂在未决集合里等着。1.3 为什么说普通信号是不可靠的这里有个历史包袱。早期 Unix 的信号实现里处理函数执行时会自动把信号动作重置为默认值也就是所谓的不可靠信号。Linux 沿用了这套编号但语义上做了改进通过signal()设置的处理函数默认会保持有效。不过不可靠这个词还有另一层含义一直保留着——普通信号不支持排队。举个具体场景一个进程把SIGUSR1加入了阻塞集合此时外部连续发了 1000 次SIGUSR1。等进程解除阻塞它只会处理一次。因为在未决集合里普通信号就是一个位bit置位了就是置位了重复置位不增加计数。这一点和后面的实时信号34 到 64完全不同实时信号是带计数和排队的。我在一个采集程序里就吃过这个亏主进程给工作进程批量推任务用SIGUSR1通知有新任务工作进程在处理函数里从共享内存取一批。压测的时候发现任务数对不上丢了大概百分之十几。后来才想明白短时间内密集发送同一个信号未决位只会置一次。需要计数语义的场景绝对不能用普通信号当通知机制。1.4 默认动作的四种归宿每个信号都有一个默认动作捕获不了的时候就走默认。Linux 里主要分四类Term终止进程不产生 coreCore终止进程并生成 core dump方便事后用调试器看现场Ign忽略什么也不做Stop/Cont停止或继续进程走的是作业控制那一套。还有两个信号是特例SIGKILL和SIGSTOP。它们的默认动作不可更改既不能被捕获也不能被阻塞更不能被忽略。内核处理这两个信号时绕过了用户态处理函数这一环直接执行动作。这是刻意设计的——总得给系统留两个终极手段否则一个失控的程序完全可以把自己的死亡按钮禁掉。2. 1 到 31 号标准信号逐段扒开看2.1 编号 1 到 8终端、硬件异常与调试陷阱SIGHUP (1)本意是挂断源自早期终端物理断线的场景。现在的用法完全变了味绝大多数守护进程把SIGHUP重定义成重新加载配置文件比如 nginx、sshd、rsyslog 都这么干。原因是历史上守护进程脱离终端后终端关闭时内核会向会话首进程发SIGHUP程序干脆把这个信号利用起来。默认动作是 Term。SIGINT (2)就是CtrlC默认 Term。SIGQUIT (3)是Ctrl\默认 Core。这两个的区别很值得记住程序卡在一个死循环里CtrlC没反应可能被忽略或阻塞了可以试试Ctrl\如果能让它吐个 core 出来调试器里就能直接看到卡在哪一行。SIGILL (4)非法指令通常是编译产物和 CPU 不匹配比如在 ARM 板上跑了 x86 的二进制默认 Core。SIGTRAP (5)是断点陷阱调试器设的断点、int3指令都走它默认 Core。SIGABRT (6)由abort()函数触发glibc 在检测到堆内存被破坏比如 double free、heap overflow时也会调它默认 Core。SIGBUS (7)总线错误最典型的是内存映射文件被截断后还在访问默认 Core。SIGFPE (8)名字叫浮点异常但整数除零也会触发它默认 Core。我把这 8 个信号分成一组是因为它们里有 5 个3、4、5、6、7、8默认带 core dump。线上排查段错误第一件事就是确认 core 文件的落盘路径和ulimit -c的限制不然拿到SIGSEGV也不知道现场在哪。2.2 编号 9 到 15日常运维真正高频的那几个SIGKILL (9)不用多说Term不可捕获不可阻塞。SIGUSR1 (10)和SIGUSR2 (12)是两个完全交给用户自定义的信号默认动作都是 Term。这一点要注意如果你的程序没有为它们注册处理函数收到就会直接死掉。很多程序约定SIGUSR1用来切换日志、SIGUSR2用来 dump 状态但这是约定不是标准。SIGSEGV (11)段错误访问了非法地址默认 Core。SIGPIPE (13)是管道破裂——向一个已经关闭读端的管道写数据就会收到它默认动作是 Term这个默认行为坑过无数人。写网络服务的时候客户端断开后继续write进程直接被SIGPIPE干掉。正确做法是在服务启动时就把SIGPIPE设成SIG_IGN然后靠write返回的EPIPE错误码来处理。SIGALRM (14)由alarm()或setitimer(ITIMER_REAL)触发默认 Term。SIGTERM (15)是kill命令不带参数时的默认信号Term可捕获。这就是优雅退出的标准入口——收到它之后该关连接关连接、该落盘落盘然后自己调exit()。2.3 编号 16 到 31Linux 私有和容易被忽视的一批SIGSTKFLT (16)是 Linux 独有的原本为协处理器栈错误预留现在基本没什么场景会触发默认 Term。SIGCHLD (17)默认动作是Ign注意是忽略而不是终止。子进程退出、停止、继续时内核会给父进程发这个信号。默认忽略听起来很省事但父进程不去wait()回收的话子进程会变成僵尸占着进程表项不放。SIGCONT (18)默认 Cont让停止的进程继续跑哪怕它被阻塞了也会强制执行。SIGSTOP (19)默认 Stop不可捕获不可阻塞。SIGTSTP (20)是CtrlZ默认 Stop但它是可以被捕获和忽略的这就是为什么有些程序CtrlZ挂不起来。SIGTTIN (21)和SIGTTOU (22)分别对应后台进程读、写终端默认 Stop走作业控制。SIGURG (23)默认 Ign用于带外数据到达的通知。SIGXCPU (24)和SIGXFSZ (25)分别对应 CPU 时间超限和文件大小超限默认 Core跟setrlimit配合使用。SIGVTALRM (26)走虚拟时钟SIGPROF (27)走ITIMER_PROF常用于性能剖析。SIGWINCH (28)默认 Ign终端窗口大小变了就发一次。SIGIO (29)默认 Term部分环境是 Ign配合fcntl的O_ASYNC做异步 IO 通知。SIGPWR (30)是电源故障通知默认 Term一般只有特定硬件平台才会发。SIGSYS (31)是非法系统调用比如用seccomp过滤掉了某个系统调用后进程还在调它默认 Core。2.4 31 个信号速查表编号名称默认动作可否捕获典型触发场景1SIGHUPTerm可终端断开、守护进程重载配置2SIGINTTerm可CtrlC3SIGQUITCore可Ctrl\4SIGILLCore可非法指令5SIGTRAPCore可调试断点6SIGABRTCore可abort()、堆损坏7SIGBUSCore可映射文件被截断8SIGFPECore可整数除零、浮点异常9SIGKILLTerm不可kill -910SIGUSR1Term可用户自定义11SIGSEGVCore可非法内存访问12SIGUSR2Term可用户自定义13SIGPIPETerm可向已关闭管道写14SIGALRMTerm可alarm()、setitimer15SIGTERMTerm可kill 默认信号16SIGSTKFLTTerm可保留未用17SIGCHLDIgn可子进程状态变化18SIGCONTCont可继续被停止的进程19SIGSTOPStop不可强制停止20SIGTSTPStop可CtrlZ21SIGTTINStop可后台读终端22SIGTTOUStop可后台写终端23SIGURGIgn可带外数据到达24SIGXCPUCore可CPU 时间超限25SIGXFSZCore可文件大小超限26SIGVTALRMTerm可虚拟时钟到期27SIGPROFTerm可ITIMER_PROF 到期28SIGWINCHIgn可窗口大小变化29SIGIOTerm可异步 IO 就绪30SIGPWRTerm可电源故障31SIGSYSCore可非法系统调用注意这张表是按 x86/x86_64/ARM 这套主流架构来的。MIPS、Alpha、SPARC 的信号编号顺序不太一样写跨平台代码的时候别硬编码数字用宏。3. kill、trap、sigaction三种层面的实操差异3.1 kill 命令和 kill(2) 系统调用容易混淆的地方kill这个单词本身就容易让人误解成杀死其实它的本职是发送信号。kill 1234等价于kill -TERM 1234发的是 15 号SIGTERM不是 9 号。这一点我在带新人的时候反复强调kill不等于kill -9。kill -l能列出当前系统支持的所有信号名包括实时信号。kill -0 PID是一个很实用的小技巧发 0 号信号不触发任何动作只做权限和存在性检查返回 0 说明进程存在且你有权限操作它返回非 0 就能据此判断进程是否还在。写脚本做进程存活检测用这个比ps加grep靠谱得多。kill(2)系统调用和kill命令的关系是命令只是系统调用的壳。真正干活的是kill(pid_t pid, int sig)第一个参数还能用负数表示进程组-1表示所有有权限操作的进程。发送方和接收方的权限规则要留意普通用户只能给同 UID 的进程发信号root 不受这个限制。3.2 Shell 脚本里 trap 的写法与常见误用Bash 里用trap注册信号处理。最基础的写法是trap echo 收到 TERM开始清理; rm -f /tmp/work.$$; exit 0 TERM INT这段代码在脚本收到SIGTERM或SIGINT时会执行清理再退出。有几个坑必须说清楚。第一trap处理的信号不能是SIGKILL和SIGSTOP写了也没用。第二trap注册的处理只有在 shell 停在安全点时才会触发——如果 shell 正卡在等待某个前台子进程得等子进程返回才会处理。第三Bash 在收到信号后会等当前前台命令执行完才执行 trap所以一个卡死的子命令会让脚本看起来没响应。还要注意trap有个EXIT伪信号无论脚本怎么退出都会触发非常适合放统一的清理逻辑cleanup() { echo 清理临时文件 rm -f $TMPFILE } trap cleanup EXIT TMPFILE$(mktemp)这个写法比在每个分支里手动清理靠谱得多。我在批量处理脚本里全靠它保证中间失败也不留垃圾文件。3.3 C 代码里 sigaction 比 signal 强在哪里signal()是老接口跨平台语义不一致有些系统上处理一次之后就恢复默认动作而且拿不到信号来源、屏蔽字这些信息。生产代码应该用sigaction#include signal.h #include stdio.h #include unistd.h static volatile sig_atomic_t g_stop 0; static void on_term(int sig) { (void)sig; g_stop 1; /* 只做一件事置标志位 */ } int main(void) { struct sigaction sa; sa.sa_handler on_term; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; if (sigaction(SIGTERM, sa, NULL) -1) { perror(sigaction); return 1; } while (!g_stop) { /* 主循环干活 */ sleep(1); } printf(检测到退出信号执行清理\n); return 0; }这段代码里最关键的设计是信号处理函数只置了一个volatile sig_atomic_t标志位真正的清理放在主循环里做。为什么这么写下一节详细说。4. 信号为什么会丢屏蔽字、pending 集合与竞态4.1 未决集合与阻塞集合的配合关系每个进程更准确说是每个线程有两套位图阻塞集合blocked也叫信号屏蔽字和未决集合pending。信号到达时先看阻塞集合没屏蔽就立刻递送屏蔽了就挂到未决集合里。用sigprocmask可以改阻塞集合。这里有个容易搞错的地方sigprocmask在多线程程序里行为是不确定的POSIX 规定应该用pthread_sigmask。线程模型下信号只递送给某一个不阻塞该信号的线程具体是哪个由内核挑。多线程服务里如果不想让工作线程被打断常见做法是在主线程启动时就阻塞掉所有关心的信号然后每个工作线程继承这个屏蔽字最后主线程用sigwait或sigwaitinfo专门处理。还有一个经典竞态想临时屏蔽一个信号、改点东西、再恢复。如果这段时间有信号到达恢复之后才处理是对的但代码逻辑如果写成先检查条件再解除屏蔽检查和处理之间就可能漏掉信号。标准解法是sigsuspend——它原子地把屏蔽字设成指定值并挂起等待回来时再恢复原屏蔽字整个过程不可被打断。4.2 自管道技巧解决竞态信号处理函数里不能干复杂的事那如果确实需要信号来了就唤醒主循环去处理呢业界通用做法是自管道技巧self-pipe trick创建一个管道把读端注册到select/poll/epoll里信号处理函数里只做一件事——往管道写端写一个字节。主循环从epoll醒来看到读端可读就知道有信号到了这时候在正常上下文里做任何复杂操作都安全。static int g_pipefd[2]; static void on_signal(int sig) { (void)sig; char b x; ssize_t n write(g_pipefd[1], b, 1); /* write 是异步信号安全的 */ (void)n; }现在更推荐的替代方案是signalfdLinux 特有能把信号变成一个可读的文件描述符直接和epoll那套事件循环无缝结合比自管道干净。4.3 处理函数里绝对不能做的事信号处理函数运行在一种非常受限的环境里它可能打断主流程在任意一条指令上执行包括打断malloc内部数据结构的更新。所以处理函数里只能用异步信号安全函数POSIX 有一份白名单常用的有write、read、open、close、_exit、kill、sigaction等数量很少。明确不能用的大概有这些printf、malloc、free、syslog、exit要用_exit以及所有标准 IO 函数。我见过一个案例处理函数里调了printf压力测试时偶发死锁gdb 打进去一看printf在等内部锁而那个锁正好被信号打断的那个线程握着。这种 bug 极难复现也极难定位。归纳成一条实操原则信号处理函数只做三件事——置标志位、写管道、调_exit。其他全丢给主循环。5. 排错实录四类典型的信号相关故障5.1 进程停在 D 状态kill -9 也没反应的真相ps aux看到的D状态是不可中断睡眠。进程此时陷在内核里等 IO比如 NFS 挂载点失联、磁盘硬件故障、块设备驱动卡住。SIGKILL虽然是终极手段但它也得等进程从内核态返回到用户态边界才能被处理——处在 D 状态的进程根本没机会走到那个边界所以信号一直挂起看起来杀不死。这时候能做的不多。排查方向是搞清楚它在等谁cat /proc/PID/stack能看到内核栈cat /proc/PID/wchan看它在等哪个函数dmesg找有没有 IO 错误。真正的解法通常是恢复底层资源比如 NFS 服务端重新上线或者重启机器。把 NFS 的挂载参数从硬挂载改成软挂载、加上intr和超时能大幅降低这类问题的影响面。5.2 守护进程收不到 SIGTERM 的三种原因容器里最常见的一类问题docker stop之后容器要么等十秒超时被强杀要么干脆不退出。排查思路有三条。一是进程把SIGTERM显式设成了SIG_IGN或者注册了处理函数但里头的清理逻辑卡住了。二是 PID 1 的特殊性——在容器里 PID 1 进程对没有注册处理函数的信号有特殊规则内核不会用默认动作处理它。如果主进程是个 shell 脚本它会继承这个行为导致SIGTERM被静默忽略。三是信号发给了错误的进程比如脚本用nohup起了子进程但kill的是脚本自己的 PID子进程根本没收到。第二条尤其隐蔽。解决办法要么是让真正的业务进程直接当 PID 1要么用exec把 shell 替换掉要么在脚本里显式trap并转发。我在 Kubernetes 里调试优雅退出的时候被这个坑折腾了整整一下午。5.3 僵尸进程与 SIGCHLD 的默认动作SIGCHLD默认是 Ign这给人一种不用管的错觉。实际上忽略的是通知不是回收。父进程不调wait/waitpid子进程退出后它的进程表项会一直保留状态显示为Z直到父进程退出被 init 接管。排查命令很直接ps -eo pid,ppid,stat,cmd | awk $3 ~ /Z/一眼就能看出哪些是僵尸以及它们的父进程是谁。修复方式是在父进程里循环回收while (waitpid(-1, NULL, WNOHANG) 0) { }这里有个细节SIGCHLD也是普通信号多个子进程同时退出可能只触发一次通知所以必须用while循环配合WNOHANG一次性收割干净不能只wait一次就完事。5.4 系统调用被信号打断EINTR 与 SA_RESTART慢速系统调用read、write、accept、select、sleep之类在执行过程中被信号打断时会提前返回并把errno设为EINTR。这是一个非常容易被忽略的返回值。有两种处理方式。一是注册处理函数时加SA_RESTART标志内核会自动重启被中断的系统调用代码看起来简洁。但要注意SA_RESTART不是万能的select、poll、epoll_wait、nanosleep这些即使加了标志也不会自动重启。二是老老实实写重试循环ssize_t r; do { r read(fd, buf, sizeof(buf)); } while (r -1 errno EINTR);我的建议是不要依赖SA_RESTART因为它的覆盖范围随平台和调用类型变化显式重试循环更可控。写网络服务的时候accept返回EINTR是常态漏了这个判断会导致连接莫名其妙被丢弃。6. 自己动手验证两个能直接跑的探测工具6.1 一个 C 小程序把 31 个信号的默认动作打印出来与其死记这张表不如写个程序让系统告诉你答案。思路是先忽略信号用signal(sig, SIG_IGN)不被允许的信号会失败再按编号顺序扫描用strsignal拿描述文本#include signal.h #include stdio.h #include string.h int main(void) { for (int s 1; s 31; s) { const char *name strsignal(s); if (!name) continue; struct sigaction old, test; memset(test, 0, sizeof(test)); test.sa_handler SIG_IGN; int catchable 1; if (sigaction(s, NULL, old) 0) { unsigned long long f (unsigned long long)old.sa_flags; (void)f; } if (sigaction(s, test, old) -1) { catchable 0; /* SIGKILL / SIGSTOP 走到这里 */ } else { sigaction(s, old, NULL); /* 恢复原动作 */ } printf(%2d %-12s %s\n, s, name ? name : ?, catchable ? 可捕获 : 不可捕获); } return 0; }编译运行gcc -o siglist siglist.c ./siglist你会看到 9 号和 19 号那两个被标成不可捕获正好印证了前面说的特例。这个程序我常拿来做环境体检尤其在陌生架构的机器上确认信号行为是否符合预期。6.2 一个脚本复现信号丢失眼见为实想亲眼看到普通信号不排队用 shell 就能做。原理是利用 shell 的 trap 在处理时本身有延迟连续快速发同一个信号处理次数会少于发送次数#!/bin/bash count0 trap count$((count1)) USR1 for i in $(seq 1 1000); do kill -USR1 $$ done # 给 trap 一点时间把积压的处理完 sleep 1 echo 发送 1000 次实际处理 $count 次跑下来你会发现计数远小于 1000。换成实时信号SIGRTMIN0那批再做同样的事计数才会接近发送次数。这是我给团队讲信号时用的演示比讲十分钟理论管用。提示这个演示依赖 shell 的信号处理时序不同 shell 表现会有差异。想看更严谨的结论用 C 写在信号被阻塞期间批量发送再解除阻塞观察处理函数被调用了几次结果是确定的——普通信号就是只处理一次。最后分享一个我个人在实际操作中形成的习惯任何需要优雅退出的服务我都会先写一个最小验证程序用kill -TERM、kill -INT各打一遍确认退出耗时和资源释放顺序再动生产代码。信号这东西默认行为和阻塞语义的细节太多光靠读代码很难判断对错跑一遍比想十遍都实在。至于那 31 个信号的编号和默认动作我建议至少把 1、2、3、9、13、15、17、19 这几个刻进肌肉记忆里日常运维九成以上的场景都在这个范围内。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进