
做Linux后台开发和运维的朋友大概率都遇到过这种“进程突然没了”的事故服务跑得好好的日志没有任何异常输出ps一看PID已经消失kill -0都毫无响应。第一反应是去看磁盘、看CPU最后翻到dmesg才恍然大悟——原来是被OOM Killer选中一个SIGKILL直接被带走。Linux信号这个机制听起来像操作系统教科书里的老古董但你每天按的CtrlC、shell脚本返回的137和143、以及后台进程在终端关闭时被清理全部都在跟它打交道。这篇东西里我想把信号机制从内到外捋一遍信号怎么产生、内核怎么递送、每个信号默认行为是什么、自己写handler要遵守哪些安全红线、线上事故怎么用信号排查以及面试里绕不开的那几个延伸问题。1. 先从“进程突然没了”讲起信号的产生与递送链条1.1 三类信号源键盘、异常、显式调用信号不是凭空出现的。从来源看无非三大类第一类来自终端和键盘你在终端里按下的CtrlC会产生SIGINT按CtrlZ会产生SIGTSTPCtrl\产生SIGQUIT。第二类来自CPU异常和非法操作比如程序把一个野指针的地址当内存来读CPU触发缺页异常内核识别出这是非法访问就给进程发SIGSEGV踩到未定义指令、除零运算依次对应SIGILL、SIGFPE。第三类是显式调用也就是进程之间或进程自身主动发出的信号比如你在命令行里输kill -TERM 或者程序里调用raise()、kill()、sigqueue()。理解信号的第一关键点是信号不是“内核为了管教你”才发明的它本质上是一个标准的进程间通信机制只是比管道、消息队列更“简单粗暴”。内核只是信号的搬运工它负责把A进程发出的信号放进B进程的“未决信号队列”等B进程回到用户态后去处理。所以信号的产生并不代表信号已经被处理了中间还有一段路程要走。1.2 内核的信号模型产生、排队、递送在Linux里一个信号从产生到最终被处理经历“生成generation→ 挂起pending→ 递送delivery→ 处理handling”四个阶段。信号生成后先记录在目标进程的描述符里这个状态叫挂起直到内核调度到该进程从内核态返回用户态的前夕才会检查未决信号集合然后执行对应的动作。这个动作可能是忽略、默认处理或者调用你注册的信号处理函数。这里有个特别容易踩的坑标准信号1号到31号是不排队的。一个进程在短时间内连续收到3次SIGINT内核通常只会在未决信号里置一个pending位后面的信号会丢失——因为它们没有队列来保存次数。只有实时信号SIGRTMIN到SIGRTMAX才支持排队每个信号可以挂在不同的优先级上并且可以携带额外数据。所以如果你在设计业务系统时想靠“发送多次自定义信号让进程执行多次任务”千万别用SIGUSR1、SIGUSR2这种标准信号中间丢失一次两次根本不讲道理。1.3 默认行为出现的时机为什么要特意强调“默认行为”因为信号处理有两种路径有注册handler就执行handler没注册handler就走内核预置的默认动作。默认动作并不是统一“终止进程”而是五花八门——有的终止、有的停止、有的忽略、有的还顺手生成一份core dump文件。你注册handler之后内核在递送信号时走的就是自定义路径。但这里有个隐含规则需要记住SIGKILL和SIGSTOP是仅有的两个既不能被捕获、也不能被忽略、还不能被阻塞的信号。这是内核留给管理员的最后一层保底手段。如果所有信号都能被进程重写handler那杀掉一个失控进程就会变成“纸上谈兵”——你连kill -9都叫不停它。所以从设计角度想这两道“铁闸”的存在非常合理。2. 把31个标准信号过一遍每个默认动作都有它的逻辑2.1 高频信号行为速查表我工作中真正频繁打交道的信号其实不超过10个但为了排查时心里有数还是把Linux标准信号的整体面貌整理一下。注意各架构的信号编号略有差异以下按x86_64的常见情况说明。编号信号名默认动作典型触发场景1SIGHUP终止终端挂断、控制终端关闭、Nginx reload2SIGINT终止CtrlC3SIGQUIT终止并生成coreCtrl\4SIGILL终止并生成core非法指令、指令编码异常5SIGTRAP终止并生成core断点指令gdb调试依赖6SIGABRT终止并生成coreabort()被调用7SIGBUS终止并生成core总线错误、非对齐内存访问8SIGFPE终止并生成core除零、浮点运算异常9SIGKILL终止kill -9不可捕获不可忽略10SIGUSR1终止用户自定义11SIGSEGV终止并生成core段错误、访问非法地址12SIGUSR2终止用户自定义13SIGPIPE终止向无读端的管道/连接写数据14SIGALRM终止alarm()定时器到期15SIGTERM终止kill默认信号优雅终止16SIGSTKFLT终止x86特有协处理器栈错误17SIGCHLD忽略子进程退出/停止时通知父进程18SIGCONT继续让停止的进程继续运行fg命令依赖19SIGSTOP停止CtrlZ之前的强制暂停不可捕获不可忽略20SIGTSTP停止CtrlZ可捕获可忽略21SIGTTIN停止后台进程从终端读输入22SIGTTOU停止后台进程向终端写输出23SIGURG忽略socket收到带外数据24SIGXCPU终止并生成core超过CPU资源限制25SIGXFSZ终止并生成core超过文件大小限制26SIGVTALRM终止虚拟定时器到期27SIGPROF终止profiling定时器到期28SIGWINCH忽略终端窗口大小变化29SIGIO/SIGPOLL终止异步IO事件就绪30SIGPWR终止电源故障31SIGSYS终止并生成core非法系统调用2.2 为什么SIGKILL和SIGSTOP杀不死网上总有人问“kill -9杀不死进程怎么办”其实分两种情况。如果进程处于R、S、Z状态kill -9一般都能生效真正无解的是D状态——不可中断睡眠进程在内核态等待IO比如正在等磁盘控制器返回、等NFS网络IO。这种状态下进程根本不会调度到用户态去处理信号信号就挂在pending队列里直到IO返回才能腾出手来处理。所以kill -9“杀不死”的进程准确说是“暂时杀不死”等IO恢复了它还是会死。排查时可以看ps输出的D状态顺便看dmesg确认是否磁盘、网络有问题再不济只能等IO超时或重启系统。另一个灵魂问题是为什么内核一定要把SIGKILL设计成“用完即走”不给任何清理机会因为失控的进程很可能连信号处理函数的执行路径都是坏的——比如栈损坏、堆损坏这时候让你跑一段复杂的handler反而是帮倒忙。SIGKILL直接让kernel接管清理简单、可靠、可预期。2.3 崩溃三兄弟SEGV、BUS、ABRT程序崩溃时你最常在core文件或日志里看到这三个信号。SIGSEGV是访存越界比如空指针解引用、栈溢出、写只读内存。SIGBUS则更“底层”一点通常是硬件层面的错误比如内存映射文件访问越过了文件末尾、CPU访存地址没有对齐——在SPARC、ARM等对对齐要求严格的平台上尤其常见。SIGABRT几乎都是代码主动abort()出来的比如glibc检测到malloc堆损坏、C异常未捕获导致terminate()都会触发abort。排查崩溃时我在拿到core文件后第一件事是看gdb的bt全栈第二件事是确认信号类型。如果收到的是SIGABRT多半是逻辑检测失败而不是内存非法访问如果收到SIGSEGV则优先怀疑指针和边界问题。这两者的排查路径完全不同先看信号能帮你省不少时间。3. 写信号处理代码sigaction与“安全handler”的硬规则3.1 signal() 和 sigaction()老接口的地雷如果你去网上搜“Linux信号处理”十有八九会看到signal()这个老接口。我强烈建议任何新代码都用sigaction()甚至是可以直接无视signal()的存在。原因是signal()在不同Unix历史实现里的语义并不统一在早期System V上是“handler执行完之后自动恢复默认动作”在BSD上又是“执行期间自动屏蔽同信号”。Linux的glibc里signal()为了兼容早年的语义做过妥协行为在历史上多次变化现在的glibc实现其实等价于带SA_RESTART的sigaction但依赖于glibc的版本和架构可移植性依然很差。sigaction()把“信号处理函数、信号掩码、行为标志”打包成一个结构体一次配置清楚#include signal.h #include string.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; memset(sa, 0, sizeof(sa)); sa.sa_handler on_term; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; if (sigaction(SIGTERM, sa, NULL) 0) { perror(sigaction); return 1; } while (!g_stop) { sleep(1); } puts(got SIGTERM, now exit); return 0; }这里面几个细节值得说。sigemptyset(sa.sa_mask)是清空“在处理这个信号期间额外屏蔽的信号集合”一份干净的初始化必须做否则结构体的残留值可能导致不可预测行为。SA_RESTART标志的意思是这个信号递送时如果当前进程正阻塞在慢速系统调用read、write、accept这类上内核会自动重新启动系统调用而不会让它返回EINTR错误。这个对生产代码影响很大下面第5章还会细讲。3.2 异步信号安全为什么不能在handler里printf信号处理函数严格来说是在“打断正常执行流”的上下文里运行的。想象一下主线程正在调用malloc()给一段链表分配内存malloc内部可能维护着全局锁这时来一个信号进程跳进你的handlerhandler里又调用malloc()好了锁被同一线程重复加直接死锁。printf、fprintf、sprintf这类stdio函数底层都和malloc、全局缓冲区锁有关所以也不安全。严格定义下handler里只能调用man 7 signal列出的async-signal-safe函数包括read、write、open、close、waitpid、sigaction等数得过来的几十个系统调用。我实测过一个非常典型的坑早期把日志打印写进了SIGTERM的handler里线上流量一上来进程收到信号时handler里的printf卡死整个进程挂在那里既没退出也没响应反而比不处理更糟糕。处理方法很简单handler里只做两件事设置一个volatile sig_atomic_t类型的标志位或者向管道写一个字节通知主循环退出。其余所有清理工作——写日志、关数据库连接、释放内存——全部放到主循环或专门的清理函数里做。3.3 自管道模式把信号搬进主循环复杂的信号处理需求比如“收到SIGUSR1后重载配置”“收到SIGTERM后优雅关闭连接”用简单的标志位虽然能保证安全但主循环的poll/select阻塞问题不好处理。这里推荐经典的自管道self-pipe模式初始化时pipe()创建一对管道fd信号handler里只做write(fd[1], x, 1)主循环保留一个fd来监听的fd[0]。这样信号的到来就等价于“一个文件描述符可读了”正好可以跟网络连接、定时器等事件统一放进poll/select/epoll里处理完全不破坏原有的Reactor结构。static int signal_pipe_w -1; static void on_signal_for_pipe(int sig) { (void)sig; unsigned char byte 0x01; ssize_t ignored write(signal_pipe_w, byte, 1); (void)ignored; }配合epoll或者poll的时候一旦检测到pipe可读就read掉字节然后根据预先记录好的“当前是什么信号”做对应的重载、关闭等逻辑。这个模式的好处有两个第一是信号处理完全集中到主循环不用在异步上下文里做危险操作第二是天然解决了“正在阻塞poll/select收不到信号”的问题因为poll本身就监听pipe读端。3.4 用SIGCHLD收尸避免僵尸进程最后聊一个高发问题僵尸进程。子进程退出后内核不会立刻把它的进程描述符完全回收而是保留着等待父进程调用wait()或waitpid()来“收尸”。如果父进程不处理子进程就变成僵尸。虽然僵尸不占内存但每个僵尸都会占一个进程表项积累多了后果很严重比如pid耗尽、运维上看到一堆defunct进程。标准解法是注册一个SIGCHLD的handler里面循环waitpid(-1, NULL, WNOHANG)把所有已经退出的子进程都回收掉static void on_child(int sig) { (void)sig; while (waitpid(-1, NULL, WNOHANG) 0) { /* 循环回收 */ } }关键点是WNOHANG不能省否则handler里waitpid()在没有子进程退出时会一直阻塞直接拖垮信号处理流程。另外多线程程序里SIGCHLD仍然只发给主进程的某个线程处理时注意线程上下文。4. 实战排障你的进程为什么“不辞而别”4.1 第一现场退出码、dmesg、/proc/ /status接到“进程没了”的告警我通常会按下面这个顺序排查能省下大量瞎猜时间。首先看父进程拿到的退出码。如果进程是被信号杀死的shell或者启动它的服务框架会把退出状态编码成“128信号编号”。比如kill -9杀掉的进程退出码是1371289kill -15退出的进程退出码是14312815。看到137、143、13912811SIGSEGV这些特征数字基本就能定位到是哪类信号干的。其次看dmesg尾部重点是“Out of memory: Kill process”这条OOM记录以及带“segfault at”字样的段错误内核日志。有一次排查线上Java进程崩溃业务日志和GC日志都没有异常最后是dmesg里一句“segfault at 0 ip 0x... error 4”指明了问题方向——代码里有非法指针访问。再高级一点直接读状态文件。每进程都有/proc/pid/status里面有SigPnd、ShdPnd、SigBlk、SigIgn、SigCgt几个字段都是十六进制位掩码。SigCgt表示“进程捕获了哪些信号”SigBlk表示“屏蔽了哪些信号”SigIgn表示“忽略哪些”SigPnd是挂起未决的。用位掩码一拆就清楚知道这个进程对信号的态度了。4.2 OOM Killer 的“暗杀”记录OOM Killer是信号排查里的一个常客。Linux允许进程过度申请内存overcommit当你申请内存时内核可能先答应等到真正触碰物理内存才发现不够OOM Killer就被唤醒了。它会遍历所有进程按照oom_score挑一个“替罪羊”直接发SIGKILL。dmesg里通常能看到类似这样的记录Out of memory: Killed process 12345 (java) total-vm:... anon-rss:...SIGKILL不可捕获、不可阻塞所以受害进程毫无还手之力。生产上的常规保护手段有两个方向一是把关键进程的/proc/pid/oom_score_adj调低比如数据库、网关这类核心服务可以设成-500甚至-1000让OOM Killer尽量跳过它们二是给机器预留足够的swap和内存余量避免走到oom这一步。你还可以通过/proc/pid/oom_score提前看每个进程的“易杀指数”指数越高被选中的概率越大。另外一个很容易被忽视的细节OOM Killer并不是“内存不够才触发”而是“内存已经溢出了而不得不触发”。排查时如果只是盲目加内存规格而不找到谁在疯狂分配问题仍然会复现。4.3 strace抓信号看谁在给谁发信号当你知道进程收到过某个信号但不确定是谁发来的strace就是最直接的工具。用strace -p pid -e tracesignal -f可以实时打印进程接收和处理信号的完整过程不仅能抓到SIGTERM、SIGINT这类高频信号也能看到sigaction注册、信号处理函数的返回情况。-f选项一定要带上否则多线程进程里其他线程的信号你根本看不到。不过要提醒一句strace会显著降低进程性能线上排查时如果你附着到一个正在服务的大流量进程上一定要有“拖慢服务几秒到几十秒”的心理准备。更安全的做法是用gdb把进程点住调栈或者临时降低流量后再strace。# 查看进程当前信号相关状态 cat /proc/pid/status | grep -E Sig(Blk|Ign|Cgt|Pnd) # 跟踪特定进程的信号事件 strace -p pid -e tracesignal -f # 列出系统支持的全部信号 kill -l4.4 经典的SIGPIPE写一个没人读的管道SIGPIPE是个典型的“默认动作很残忍但又是设计使然”的信号。进程向一个已经关闭读端的管道写数据时write()会触发SIGPIPE默认行为是直接把进程终止。在命令行里跑yes | head -n 1这类操作yes进程会很快被SIGPIPE杀掉这就是它的正面作用——及时终止一个没有意义的写方。但对网络服务程序来说SIGPIPE是灾难。假设你写了一个TCP服务客户端断开连接后你还在send()数据服务端进程就会收到SIGPIPE直接退出。一个客户端频繁断开就能把你的服务打死。所以几乎所有正经服务端程序都要显式忽略SIGPIPEsignal(SIGPIPE, SIG_IGN);忽略之后send()/write()会返回-1并设置errno为EPIPE这时候你就走自己的错误处理逻辑比如清理连接、打点日志。别小看这一行多少初学socket编程的朋友被“客户端一断网服务就挂”的诡异问题折磨过根因就是没忽略SIGPIPE。5. 从信号看系统设计终端、nohup、线程与实时信号5.1 终端关闭和SIGHUPnohup到底做了什么SIGHUP的原始含义是“终端挂断HangUP”。早期Unix用户是通过串行终端连接的电话线路一断终端驱动就给会话首进程发SIGHUP什么程序都被迫终止。今天我们在笔记本电脑上已经不会遇到电话线断了这回事但终端窗口关闭时内核依然会模拟这一套逻辑控制终端关闭后内核向前台进程组和会话首进程发送SIGHUP。这也是为什么你直接在bash里跑一个后台任务然后关掉终端窗口后台任务也跟着消失。想让它活下来常规做法是nohup ./your_server nohup的本质不是“守护进程化”它只是先把SIGHUP忽略掉然后执行你的命令。所以它只能帮你避开终端挂断引起的SIGHUP如果管理员手动向你进程发kill -HUP或者进程收到别的终止信号它照样死。如果你自己写守护进程更正统的做法是调用setsid()脱离当前会话不再有控制终端也就不会再被终端关闭波及。再配合fork一次、chdir到根目录、umask(0)、重定向标准输入输出到日志文件这几个步骤合起来才是一个真正的daemonize流程。5.2 信号会打断系统调用EINTR与SA_RESTART这块是一个特别容易踩坑但很少有人讲透的细节。进程如果阻塞在read()、write()、accept()这类慢速系统调用上此时信号递送过来内核会先把信号处理函数跑掉然后把阻塞中的系统调用中断使其返回-1并设置errno为EINTR。如果你的代码没有处理EINTR就可能在毫无征兆的情况下退出循环、漏掉数据、关闭连接。在信号处理函数之外代码里最标准的做法是循环检查并重试ssize_t n; do { n read(fd, buf, sizeof(buf)); } while (n 0 errno EINTR);如果你注册信号时使用了SA_RESTART标志内核会在handler执行完后自动重启被中断的系统调用很多业务代码不需要自己改。但注意SA_RESTART不是万能的它对poll、epoll_wait、select、sleep、nanosleep等少数系统调用无效这些依然会返回EINTR。所以如果你的项目里有“信号handler 阻塞poll”的组合最好还是对关键的调用保留EINTR重试逻辑。5.3 线程环境与实时信号别在handler里做越多越好多线程程序里的信号语义跟单线程差别很大。常规的kill()发送信号给进程时内核会随机挑选一个没有屏蔽该信号的线程来递送信号这导致信号处理函数的执行线程不确定、栈不一定对应主线程非常不利于排查。所以多线程程序里推荐的做法是让所有线程在启动时用pthread_sigmask()屏蔽你关心的信号单独创建一个专门的“信号线程”用sigwait()或sigwaitinfo()阻塞等待这些信号收到信号后由这个专用线程按部就班地执行清理逻辑。这等于把“信号”从异步中断模型变成了同步消息模型本质上比自管道更彻底也更适合线程环境。另一个常见应用是使用sigqueue()配合实时信号给目标进程附带整数或指针数据实现带参数的事件通知。实时信号在设计业务协议时确实好用比如你想通知进程“配置文件更新了”用SIGUSR1只能表达“有事发生”如果用实时信号SIGRTMIN1还可以通过siginfo_t里的si_value传一个版本号或者变更类型。但用实时信号要克制不要把信号当作万能通信总线信号是轻量通知机制不适合传大块数据。5.4 面试高频追问信号与中断的区别、以及“await”陷阱最后提一个面试里经常考察的点信号和中断不是一回事。中断是硬件或软件异步事件交给CPU和内核去处理发生在内核态信号是内核发给进程的通知发生在进程上下文信号处理函数跑在用户态。中断处理程序要极简信号处理函数也一样越短越好。很多人在handle里写一堆“优雅清理逻辑”结果清理逻辑本身出bug或者跟主循环抢锁导致更复杂的死锁和竞态。一个能被反复调用的handler应该像快递驿站签收包裹一样拿到通知、记一个标记后面的拆包整理回到主流程再做。还有一道实操题也常被问到“为什么程序里调用sleep()之后按CtrlC程序不是立即退出而是要等一下”因为SIGINT打断sleep()后sleep()返回剩余秒数如果代码里没有判断返回值并再次重试进程可能立刻进入下一个业务分支而不是退出而真正退出是信号处理的默认行为或者你自己的handler置了退出标志后主循环结束。这个场景正好能串起EINTR、信号标志位、SA_RESTART这几个概念想通一次这套机制就基本不会再有盲区了。我个人在使用过程中最深的体会是信号机制本身不复杂复杂的是所有跟它交互的代码路径。你可能永远不会主动去编程触发SIGSEGV但每次压测、上线、客户端反复断开时信号都在背后默默扮演着“最后一公里”的角色。如果你准备把这些整理成一套自检清单我建议至少包含新程序一律用sigaction不用signal服务端程序显式忽略SIGPIPEhandler里只写标志位或管道多线程程序统一用sigwait线程处理信号排查进程神秘退出时先看退出码、dmesg和/proc/ /status。这套组合拳打下来再诡异的信号问题也能快速缩小范围到根因。