
搞Linux的人早晚都得和“信号”打交道。你写了个服务跑得好好的突然进程没了日志上什么错都没有或者你想让Nginx重读一下配置实际上只需要给主进程发一个HUP信号再或者后端程序一接客户端就崩报错信息里写着“Broken pipe”这些都是Linux进程信号在背后起作用。这篇文章我会从信号的本质讲起把常用信号、发送方式、代码捕获、以及我这些年踩过的信号相关的坑一次性说清楚。无论你是刚接触Linux的命令行新手还是经常处理线上事故的运维或后端工程师这几千字应该能帮你把“信号”这块补得比较扎实。1. 信号到底是什么先把它比作“系统打的电话”1.1 信号的本质软件层的中断通知信号signal在Linux里是一种软件中断机制本质上是内核向进程发送的异步事件通知。你可以把进程想象成正在办公室里写代码的人信号就是突然响起的电话——这个电话可能来自上级其他进程、可能来自闹钟定时器、也可能来自保安内核发现程序踩了违规内存时强制通知。打电话这个动作是异步的你不知道它什么时候会响也不确定响完之后会不会有额外的事情要你处理。这种设计其实延续自Unix时代后来被POSIX标准化。Linux下的信号数量在不同架构上略有差别通常x86架构上编号1到31是标准信号还有一批实时信号编号32到64。标准信号每个都有固定的语义实时信号则更像纯粹的数据通道排着队按顺序送达。大部分日常运维和开发工作接触的都是前31个标准信号。相比管道、消息队列、共享内存这些进程间通信手段信号的“颗粒度”非常小它不传输大量数据只传递一个编号。但正因为轻量信号适合做事件通知而不是数据传输。跨进程发信号时你不需要建立连接也不需要考虑缓冲区代价就是信息承载量极其有限发过去之后对方要不要理你、什么时候理你全靠对方进程自己的信号处理策略。1.2 为什么进程需要信号给进程一个“外部干预”的入口没有信号机制的Linux管理进程会非常痛苦。你想让一个后台服务优雅地停止总不能直接拔电源吧常规做法是给一个可控的入口让进程自己决定怎么退出。信号正好提供了这样一个入口SIGTERM发过去进程可以做清理工作再退出SIGINT相当于在终端里跟你说“用户不想干了”SIGALRM则像定时器到点提醒“该收工了”。信号也是故障恢复的重要依赖。程序访问了非法内存地址内核会立刻给进程发SIGSEGV默认行为是终止进程并生成core文件程序写了一个已经关闭的管道另一端内核发SIGPIPE。这些故障信号让异常程序不至于继续烂下去而是快速失败给排查问题留下线索。对于后台服务类的程序来说信号一直是运维操作的主要手段。Nginx的nginx -s reload本质上是给主进程发送SIGHUP让旧的工作进程退出、重新加载配置SSH服务重启也离不开HUP信号的习惯用法。理解信号你才真正理解网上那些“kill -HUP pid 重读配置”的操作而不是把它当成一个死记硬背的魔法咒语。提示信号是异步的所以不能假设代码执行到某个确定位置时信号一定没来。这也是很多信号处理坑的根源。2. 用好信号先认清单常用信号类型深度剖析2.1 必须掌握的10个信号Linux下的信号有几十个但日常真正会高频率碰到的不超过15个。我按使用频率整理了常用信号的对照表建议把这张表存下来当速查手册信号编号默认行为常见触发场景SIGHUP1终止进程终端挂断、会话退出常被守护进程重载配置SIGINT2终止进程键盘 CtrlCSIGQUIT3终止进程并生成core键盘 Ctrl\SIGKILL9强制终止不能捕获和忽略kill -9SIGSEGV11终止进程并生成core野指针、越界访问、栈溢出SIGPIPE13终止进程向已关闭读端的管道/套接字写入SIGALRM14终止进程alarm()或定时器超时SIGTERM15终止进程kill命令默认发送的信号可捕获SIGCHLD17忽略子进程结束、暂停或恢复时发给父进程SIGSTOP19暂停进程暂停进程不能捕获和忽略SIGTSTP20暂停进程键盘 CtrlZ先说两个你可能天天用却一直没意识到的SIGINT和SIGTERM。CtrlC发的是SIGINT它通常意味着“前台进程该停了”而kill PID真正发的是SIGTERM它比SIGINT更正式很多服务端程序会专门捕获SIGTERM做优雅退出——先把连接断开、把缓冲的数据落盘再退出主循环。如果你不管三七二十一直接kill -9进程连清理机会都没有数据库或者消息队列有可能会留下没写完的事务。SIGHUP值得单独讲一下。它原始语义是“终端挂断”老式拨号终端断线时会发给会话里的进程后来很多守护进程把它“借用”成重载配置的信号。所以你会看到Nginx、sshd这些服务的官方操作文档里写着“发送HUP信号以重新打开日志文件或重新加载配置”。这不是信号设计变了而是大家约定俗成的习惯用法是实践沉淀出来的语义。比较微妙的是SIGCHLD父进程必须主动调用wait()/waitpid()去回收子进程不然子进程退出后就变成僵尸进程。这里的“僵尸”不是活僵尸而是一个已经把资源都释放干净、却还在进程表里占着一个条目的坏状态。SIGCHLD的作用就是通知父进程“你孩子没了可以收尸了”后面第4章我会详细展开。2.2 信号的默认行为分类与“不可捕获”的两个特例每种信号都有默认行为绝大多数信号默认是“终止进程”还有一部分是忽略、暂停或者继续。搞懂默认行为有个实际价值有时候你根本不需要写捕获逻辑只要把该用的信号用对系统自己会做正确的事。默认行为可以归纳为五类终止进程、终止并生成core文件、忽略、暂停进程、继续运行。生成core文件的信号值得重视core是进程崩溃瞬间的内存快照后面用gdb可以直接定位崩溃现场。我遇到过不少同事程序一崩就抓瞎其实只要确认系统没有把core关闭崩溃时就能自动生成core.*文件配合gdb的bt命令就能看到调用栈。信号里有两个绝对特例SIGKILL和SIGSTOP。这两个信号既不能被捕获、也不能被阻塞、更不能被忽略内核会无条件强制执行。为什么不把它们也做成可捕获的因为这是系统留给管理员的最后手段。想象一下某个进程陷入死循环把CPU占满了它又故意屏蔽了所有信号如果连SIGKILL都不能强制杀掉它那管理员就只能重启机器了。所以这两个信号的存在保证了系统始终有一个“物理级”的干预手段。另一个容易忽略的点是信号的“累积”与“排队”。标准信号并不排队如果信号还没处理完又来了一个相同的信号很多时候会被合并成一次实时信号则支持排队和携带额外数据。理解这点对调试有影响你在程序里连续发两个SIGUSR1handler可能只会执行一次。所以不要把信号当计数器用它只是通知。注意在shell里执行kill -l可以看到当前系统支持的全部信号列表。不同发行版和架构信号编号可能有差异以kill -l输出为准。3. 从命令行到代码信号的发送与接收实战3.1 kill命令的正确姿势它不只是“杀死”很多新手以为kill就是杀进程其实kill的全名应该是“向进程发送信号”。发送SIGTERM默认值是15如果发9就是SIGKILL。用kill -l可以列出信号名和编号对应关系我用这个命令的频率非常高尤其是一时记不清某个信号是几号的时候。基本用法其实很简单记住下面这几行就够用了kill -TERM 1234 # 等价于 kill 1234优雅终止 kill -KILL 1234 # 等价于 kill -9 1234强制终止 kill -HUP 1234 # 让进程重读配置 kill -l # 列出所有信号实战里最经典的场景是Nginx的reload。新版Nginx有nginx -s reload命令但老版本或者一些编译安装的版本很多人还是习惯kill -HUP $(cat /run/nginx.pid)。原理就是给Nginx主进程发送SIGHUP信号主进程收到后会重新加载配置、启动新的工作进程然后优雅地退出旧工作进程。整个过程对正在服务的请求影响极小。还有一个高频场景是清理“卡死”的进程。如果kill -TERM发完之后等了几秒进程还在再考虑kill -KILL。我见过有的人上来就kill -9这其实是个坏习惯。举个真实例子Java服务在写日志时被kill -9结果日志文件停留在半行下一次启动如果日志框架不自愈可能直接报错。正确的做法是先给进程一个优雅退出的机会实在不退再上强制手段。除了kill本身还要会用killall和pkill。killall nginx按进程名发信号pkill -9 -f java -jar demo.jar按命令行匹配。批量操作比较方便但注意pkill的匹配规则很容易误伤——我踩过用pkill -f test把测试环境一堆进程全部杀掉的坑所以连名带参数匹配时一定要先pgrep确认。3.2 终端键盘快捷键与后台任务的信号关系键盘上最熟悉的三个信号快捷键是CtrlC发送SIGINTCtrl\发送SIGQUITCtrlZ发送SIGTSTP。SIGINT是“中断”SIGQUIT是“退出”后者还会生成core文件适合在程序卡死时抓取证。CtrlZ则是把前台进程暂停放进后台配合fg/bg命令继续运行。如果你希望某个程序在终端关闭后依然存活常见做法是用nohup或setsid。nohup的原理就是捕获并忽略SIGHUP信号——终端关闭时系统会给会话中的进程发SIGHUP如果你把SIGHUP忽略掉进程就能活下来。setsid则是干脆让进程开启一个新的会话彻底脱离原终端。更轻量的做法是用加disown把作业从shell的作业表里摘除这样终端关闭时也不会收到SIGHUP。这里补一个现场经验用docker run跑容器时如果你用--init参数容器内的PID 1会是一个特殊的init进程它能正确地转发和处理信号如果你直接跑一个应用作为PID 1那么很多信号默认行为会和普通进程不一样。比如Java应用作为容器PID 1时docker stop发的SIGTERM可能不会正确处理导致容器要等KILL超时。这属于信号和容器运行时交织的经典坑很多人排查半天找不到原因其实源头就在PID 1的信号语义上。3.3 在代码中捕获信号从Shell到C的落地写法理解信号最好的方式是自己写一段捕获代码。先看Shell脚本里最常用的trap#!/bin/bash cleanup() { echo 收到退出信号正在清理临时文件... rm -f /tmp/demo_$$.lock exit 0 } trap cleanup TERM INT echo 进程 $$ 已启动PID$$ while true; do sleep 1 done把这个脚本跑起来然后在另一个终端kill -TERM pid你会看到脚本打印了清理信息才退出。这里的trap cleanup TERM INT意思是当进程收到SIGTERM或SIGINT时不再执行默认的终止行为而是跳转到cleanup函数。如果想恢复默认行为用trap - TERM INT即可。C语言里signal()函数是最简单的但因为它在不同Unix系统上行为有差异新手我更推荐sigaction()。下面是一个简化但完整的示例#include stdio.h #include signal.h #include unistd.h #include string.h static volatile sig_atomic_t running 1; static void handle_term(int sig) { running 0; } int main(void) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler handle_term; sigemptyset(sa.sa_mask); sa.sa_flags 0; if (sigaction(SIGTERM, sa, NULL) 0) { perror(sigaction); return 1; } printf(PID%d 等待 SIGTERM...\n, getpid()); while (running) { pause(); } printf(优雅退出清理完成。\n); return 0; }这个例子里有两个关键点一是running变量声明为volatile sig_atomic_t因为它在信号处理器和主循环之间共享需要保证读取时不会被优化出问题二是sa.sa_mask先清空表示处理该信号时不需要额外屏蔽其他信号。编译运行后kill -TERM pid程序会跳出pause()循环并打印退出信息。如果是Python这类脚本语言也有很简单的处理方式import signal import time def handler(signum, frame): print(received signal:, signum) raise SystemExit(0) signal.signal(signal.SIGTERM, handler) while True: time.sleep(1)写代码捕获信号时最容易被忽略的是信号处理函数里到底能做什么这个问题我放在第4章重点讲因为很多线上事故就是从“在信号处理器里干了不该干的事”开始的。4. 信号处理的高阶话题与常见坑4.1 信号处理函数的限制异步信号安全的那些事信号处理器是在主线程执行的任何位置“强行插入”的一段代码。这意味着它执行的时候主程序可能正停在malloc()的内部、正拿着某个锁的两端状态完全不可预测。如果handler里也调用malloc()或者调用printf()这种依赖内部缓冲区的函数就可能发生“不可重入”的问题同一个函数在中断前后被重新进入内部状态错乱表现就是程序莫名其妙死锁、崩溃或者数据损坏。所以信号处理函数里能安全使用的函数集合非常小这些函数被称为“异步信号安全”async-signal-safe函数典型包括write()、read()、open()、close()、_exit()、sigaction()等。而malloc()、free()、printf()、fopen()这类平时常用的函数基本都不在安全表里。工程上最常见的做法是“信号处理器里只设置标志位主循环里轮询处理”。我在前面C语言例子里就是这么写的handler里只做running 0真正的清理和打印全放在正常的控制流中。如果确实需要在收到信号后立刻做一些复杂操作经典的解决方案是“self-pipe trick”——在handler里往一个管道写一个字节主循环用select()或epoll()监控这个管道再在主循环上下文里做安全操作。方案不复杂效果却非常稳。另外一个高频问题是慢系统调用被信号中断后返回EINTR错误。比如进程正阻塞在read()上等网络数据这时候来了一个信号handler执行完返回后read()可能直接返回-1并把errno设为EINTR。如果你代码里没处理这个错误可能就误以为连接断了。解决办法之一是给sigaction设置SA_RESTART标志让内核在处理完信号后自动重启被中断的系统调用。老代码里经常见到的if (errno EINTR) continue;就是在手工处理这种情况。4.2 SIGCHLD与僵尸进程每个后台服务都会遇到的“收尸”问题子进程退出时内核并不会立刻把它的PID和资源记录全部清掉而是要等父进程调用wait()或waitpid()来“收尸”。如果父进程一直不调用子进程就成了僵尸进程Zombie。僵尸进程不占CPU也不占内存但它会占着PID而且可以通过ps看到一堆defunct状态的进程。PID耗尽之后新进程无法创建这是非常典型的故障。正常情况下父进程收到SIGCHLD信号后就应该去回收子进程。简单的做法是在handler里循环调用waitpid(-1, status, WNOHANG)把所有的子进程都收一遍。这里有个细节只调用一次waitpid可能不够因为多个子进程同时退出时SIGCHLD可能被合并成一个信号所以要用while循环配合WNOHANG直到返回0或-1为止。还有一种偷懒但有用的玩法把SIGCHLD的信号处理函数设置为SIG_IGN表示“父进程不关心子进程的退出状态”。当系统检测到SIGCHLD被显式忽略后子进程退出时会被内核自动回收不会产生僵尸。我常用于短生命周期的批量任务脚本里。但要注意如果你还需要子进程的退出码做业务判断这么做就没法获取了只能追求“不产生僵尸”。再讲讲“双fork”技巧。如果你写的是一个守护进程希望启动子进程的时候彻底摆脱父子关系不让父进程留下僵尸常用的做法是第一次fork()出一个中间进程中间进程立即退出真正的孙进程被内核托孤给PID 1init收养此后孙进程的退出由init负责回收。这个技巧在早期的daemon化代码里非常常见理解了SIGCHLD和僵尸进程的机制你才能真正看懂这类代码在干什么。注意在容器里PID 1往往不是传统的init如果容器内的应用作为PID 1又不主动处理SIGCHLD那子进程僵尸问题就会一直累计直到出问题。4.3 SIGPIPE为什么很多后端程序要忽略它SIGPIPE是最容易让后端工程师困惑的信号之一。触发条件很明确进程往一个已经关闭了读端的管道或者socket写入数据。比如你用curl访问一个半路关闭连接的HTTP服务服务端的响应数据写不出去内核就会给服务端进程发SIGPIPE默认行为是直接终止进程。如果服务端没有处理最直接的后果就是进程退出而你翻日志可能只看到一行奇怪的中断记录。最常见的处理方式就是忽略它。很多服务端框架在启动时都会执行signal(SIGPIPE, SIG_IGN)因为在网络编程中对端断开是非常正常的现象不应该因为对端断开就让整个服务进程崩溃。忽略SIGPIPE之后write()返回-1errno被设置为EPIPE业务代码就可以通过EPIPE来判断“对端已经关闭”做正常的连接清理。在Python里经常能看到这样的报错BrokenPipeError这其实也和SIGPIPE有关。命令行工具最常见yes | head -n 5yes一直往管道里写head只读5行就退了yes再写时就会收到SIGPIPE而终止。看起来像“出错”其实是合理的停止方式。所以很多处理大量文本的命令行工具会特意忽略或处理SIGPIPE避免输出一个难看的traceback。还有一个容易踩的坑是“同时处理SIGPIPE和EPIPE”。只忽略信号还不够业务代码需要判断写入返回值。如果你用Java、Go这类语言底层可能会自动忽略SIGPIPE把错误暴露为IOException这是安全的但在C/C里如果只忽略SIGPIPE却没有检查write()的返回值对端断开时你只会默默丢掉错误后面排查起来更难。5. 故障排查速查表由信号引发的典型问题5.1 从现象反推信号Segmentation Fault、Broken Pipe、Killed线上遇到进程退出第一反应应该看两件事一是进程退出码二是系统日志。判断是否跟信号有关有个很实用的经验公式如果进程是被信号终止的shell或父进程看到的退出码通常是128 信号编号。比如被SIGKILL9杀掉退出码是137被SIGSEGV11杀掉退出码是139。这条经验在大多数脚本和运维工具里都适用。常见的现象和对应信号我整理成了速查表你看到的提示大概率相关信号常见原因与方向Segmentation fault (core dumped)SIGSEGV野指针、数组越界、栈溢出KilledSIGKILL人为kill -9或OOM Killer触发Broken pipe / Connection resetSIGPIPE对端关闭连接继续写入终端突然hang住进程消失SIGHUP终端关闭后台会话收到挂断CtrlC无效SIGINT被忽略程序主动忽略SIGINT进程卡死不退出SIGSTOP/TSTP进程被暂停查看S状态No child processesSIGCHLD相关父进程wait逻辑有问题比如Java应用突然“Killed”dmesg里经常能看到Out of memory: Kill process ...这说明是系统内存不足触发了OOM Killer而不是业务代码崩溃。如果直接冲上去查代码方向就错了。先看dmesg | tail -n 20再对照退出码137基本能确定是不是内存层面的问题。5.2 定位信号问题的三件套ps、strace、/proc排查信号问题我最常用的工具是ps、strace和/proc文件系统。ps看的是什么状态如果想让进程暂停状态是T如果进程变成僵尸状态是Z。ps -o pid,stat,cmd -p PID可以精确地看单个进程的状态。/proc/PID/status里有一个信号相关的部分是很多资料不会重点讲的SigCgt、SigBlk、SigIgn这三项分别是这个进程捕获了哪些信号、屏蔽了哪些信号、忽略了哪些信号。比如你想知道一个进程有没有忽略SIGPIPE直接看SigIgn里对应的位就能确认。strace是观察信号到达最直观的工具。strace -p PID可以实时看到进程收到的系统调用如果配合-e tracesignal可以只追踪信号相关调用kill、rt_sigaction、rt_sigreturn都会显示出来。我第一次用strace定位一个诡异问题时特别震撼代码里完全没打印任何信息但strace清晰地显示了进程先收到SIGTERM、然后执行了handler之后卡在write上整个过程一目了然。还有一个小技巧是dmesg和core文件配合使用。进程因为SIGSEGV崩溃时内核日志里通常会有对应的记录如果系统开启了core dump还会生成core.*文件。用gdb ./程序 core.*打开core文件输入bt即可看到崩溃时的调用栈。这个组合拳对于定位“程序为什么突然没”的疑难杂症非常有效。5.3 我踩过的信号坑三个真实案例第一个坑是kill -9杀数据库。当时MySQL实例卡死我图省事直接kill -9结果重启后InnoDB一直在做崩溃恢复服务恢复时间比预期长了很多。后来我们规范了操作流程对数据库、Redis这类有持久化状态的服务一律先kill -TERM如果几分钟内没退出再考虑更强的措施。第二个坑是服务进程“静默消失”查了很多日志都没结果。后来用dmesg | grep -i kill才发现是OOM Killer干的当时那个节点的内存被一个异常膨胀的缓存占满了OOM Killer选择了分数最高的进程下手。从那以后我的监控里多了一条指标不只是CPU和内存还要盯着OOM事件和核心进程的退出码。第三个坑和SIGCHLD有关。一个监控脚本用nohup启动了很多子任务结果发现进程数量无限增长机器上出现几百个僵尸进程。排查后才发现脚本没有在收到SIGCHLD后调用waitpid回收子进程。后来我写了一个通用的启动器统一处理SIGCHLD在handler里用waitpid(-1, status, WNOHANG)循环回收问题才彻底解决。提示生产环境里给核心服务发信号之前一定要先确认PID。建议养成习惯先pgrep -f看清楚匹配到了哪些进程再执行kill避免误操作。说实话信号是Linux里“简单但极容易出问题”的机制。它的概念不难——一个编号、一份通知但真正到了生产环境你会发现很多疑难杂症最后都绕回到信号上。我个人这几年最大的体会是不要排斥命令行和底层的细节恰恰是这些基础机制决定了你排查故障时是在瞎猜还是在精准定位。自己写几个小程序亲手发信号、看状态变化、用strace观察一次信号从发起到处理的过程比背十篇文档都管用。如果身边有现成的服务也可以在维护窗口试试kill -TERM和kill -HUP的真实效果观察进程怎么退出、怎么重载配置。信号这关过了Linux系统管理的其他知识会顺很多。