排查一个跑批服务停不下来的问题时,我打开终端敲的第一条命令通常不是kill -9,而是kill -l。这个习惯是被坑出来的:早期我只会kill -9 <pid>,结果要么把服务直接打死在写文件的中途,要么发了kill <pid>之后进程纹丝不动,我还以为自己敲错了 PID。后来才明白,kill这个名字有很强的误导性,它本身并不"杀"什么,真正干活的是背后的信号机制,kill只是负责把信号递交给目标进程的一个投递员。而kill -l和kill -s恰恰是这个投递员身上最值得先搞懂的两个开关:前者负责"查邮编本",把所有信号的编号与名字对起来;后者负责"按名字点名",让你不必记住那些跨平台会变的数字编号。
这篇内容适合三类人:一是刚接触 Linux、还在用kill -9当万能钥匙的新手;二是写过启停脚本、被"信号发了没反应"折磨过的后端或运维同学;三是想把自己手上一堆kill命令从"能跑"升级成"稳妥、可回滚、可解释"的老手。我会从信号这套机制本身讲起,再把kill -l的输出拆到每一列、每一段,把kill -s的语法、等价写法、进程组语义、权限边界讲透,最后落到几个真实的排障现场:为什么信号发了进程不理你,为什么kill -9之后ps里还能看见那个 PID,为什么容器里docker stop总是等到超时才退。全是能在键盘上直接复现的东西。
1. 先把信号这层窗户纸捅破
1.1 信号本质是内核发给进程的软件中断
很多人对kill的误解,来自把"进程"想成了一个可以被外力掐死的实体。实际上进程在你的机器上是一个由内核托管的状态机,用户态程序没有任何办法直接去动另一个进程的内存或执行流。你要让另一个进程"做点什么",唯一正规的通道就是请内核代为传递一个通知,这个通知就是信号(signal)。从进程的视角看,信号的到达方式和硬件中断非常像:当前指令流被打断,内核把控制权交给进程预先注册的处理函数,或者按默认动作直接处置,处理完再恢复原来的执行。
这里有个关键点必须记住:信号有"默认动作",但不代表一定会执行默认动作。每个信号都自带一套内核定义的默认行为,常见的有终止进程(TERM、KILL、INT)、终止并产生 core dump(SEGV、ABRT、QUIT)、忽略(CHLD、URG、WINCH)、暂停(STOP、TSTP)、继续(CONT)。但进程可以主动用signal()或sigaction()给某个信号注册自己的处理函数,一旦注册成功,默认动作就被覆盖了。这就是为什么有些进程收到SIGTERM会优雅地关连接、刷盘、写日志再退出,而有些进程收到同样的信号就像没听见——不是信号没送到,是它自己接管了处理逻辑,甚至干脆把信号SIG_IGN掉了。
还有一个必须记牢的例外:SIGKILL(9)和SIGSTOP(19)既不能被捕获,也不能被忽略,更不能被阻塞。内核保证了这两个信号的"强制执行力",所以它们成了最后的兜底手段。但也正因为强制,它们跳过了进程所有善后逻辑,这就是为什么我一直把kill -9视为成本最高的一键。
1.2 一张表把常用信号的语义理清楚
信号一共几十个,但日常真正会用手敲的也就十来个。下面这张表是我自己贴在便签上的版本,按"使用频率 + 踩坑程度"排的,不是按编号排:
| 信号 | 编号(x86) | 默认动作 | 典型用途与我的使用场景 |
|---|---|---|---|
| SIGHUP | 1 | 终止 | 终端断开时由内核发给前台进程组;守护进程常借用它做"重载配置"的约定信号 |
| SIGINT | 2 | 终止 | Ctrl-C 触发,发给前台进程组;交互式中断 |
| SIGQUIT | 3 | 终止+core | Ctrl-\ 触发;Java 进程常用它做线程栈 dump |
| SIGKILL | 9 | 终止 | 不可捕获不可忽略,最终手段;会跳过所有清理逻辑 |
| SIGUSR1 | 10 | 终止 | 自定义用途,日志切割、打开调试开关最常见 |
| SIGSEGV | 11 | 终止+core | 非法内存访问,段错误的"案发现场" |
| SIGPIPE | 13 | 终止 | 往已关闭的管道/socket 写数据;网络服务要主动忽略它 |
| SIGALRM | 14 | 终止 | 定时器到期 |
| SIGTERM | 15 | 终止 | 优雅停机的标准信号,启停脚本默认就该发它 |
| SIGCHLD | 17 | 忽略 | 子进程状态变化通知,父进程据此回收,处理不当会产生僵尸 |
| SIGCONT | 18 | 继续 | 唤醒被 STOP 挂起的进程 |
| SIGSTOP | 19 | 暂停 | 不可捕获,冻结进程做现场保留,排查完再 CONT |
| SIGTSTP | 20 | 暂停 | Ctrl-Z 触发,可被捕获(shell 作业控制依赖它) |
| SIGWINCH | 28 | 忽略 | 终端窗口大小变化,TUI 程序据此重绘 |
这张表最想强调的一点是编号和名字的关系是"约定"而不是"定律"。x86 上SIGUSR1是 10,在一些架构上会是 16;SIGCHLD在多数平台是 17,个别平台是 18。写进脚本、写进配置、写进告警规则里的东西,如果用的是数字,换一台机器就可能指向另一个信号,后果不一定立刻可见,但一定会在某个深夜爆出来。这也是我后面反复推荐kill -s用名字的根本原因。
1.3 标准信号不排队,这一点决定了很多"丢信号"的假象
还有一个容易被忽略的机制:1 到 31 这些标准信号(也叫普通信号)不排队。内核在每个进程的 task_struct 里维护一个位图,某个信号处于 pending 状态时只占一个 bit,你连发三次SIGUSR1,如果进程还没来得及处理第一次,后两次会被合并掉,最终只处理一次。所以"我明明发了五次信号,日志里只出现一次回调"并不是kill丢了,而是标准信号的设计如此。
真正会排队的是 34 到 64 这段实时信号(SIGRTMIN ~ SIGRTMAX),它们带sigqueue的附加数据,且同一种实时信号可以多重排队。如果你的业务需要"每一次通知都必须被处理",那就得用实时信号,而且接收端要用sigaction配合SA_SIGINFO拿附加数据。不过绝大多数运维场景用不到这个层次,知道有这么回事、遇到"信号合并"时不要怀疑人生就够了。
2. kill -l:这份"信号字典"到底列了什么
2.1 裸敲 kill -l 的两个输出形态
直接敲kill -l,不加任何参数,输出是一张信号清单,格式大致如下(不同发行版、不同 glibc 版本排列会有细微差异):
$ kill -l 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP 21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR 31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3 ... 64) SIGRTMAX-12 65) SIGRTMAX这里有几个细节值得停下来看一眼。第一,32 和 33 是缺号的。这不是kill漏了,而是 NPTL 线程库内部把这两个编号占用了(用于线程取消和 setxid 同步),对用户态屏蔽掉,所以清单里从 31 直接跳到 34。第二,实时信号的显示方式很特别,中间部分从SIGRTMIN+1一路递推到SIGRTMAX-n,最后一行是SIGRTMAX。第三,SIGRTMIN的具体数值并不是恒定的 34,glibc 会在内部预留几个实时信号给线程库用,所以你在程序里拿到的SIGRTMIN可能比清单上显示的值更大。这一点在做信号数量计算时要留意,别拿kill -l的输出去当程序里常量用。
除了裸敲,kill -l还支持带一个参数,做双向反查:
$ kill -l 15 TERM $ kill -l TERM 15 $ kill -l SIGTERM 15 $ kill -l kill 9注意第三个例子,kill -l SIGTERM也能出 15,说明它接受带SIG前缀的写法;第四个例子大小写不敏感,kill和KILL都能识别。这两个宽容度在实际写脚本时非常省事,你不需要在拼接字符串的时候纠结前缀问题。
2.2 编号超过 128 时的特殊行为:退出码反查
kill -l还有一招很多人不知道:传进去的编号如果大于 128,它会先减 128 再查。这个设计是为了对齐 shell 的退出码约定——进程被信号 N 杀死时,shell 记录的退出码是 128+N,$?拿到的就是这个值。所以你可以直接把$?的数值丢给kill -l反查死因:
$ kill -l 137 KILL $ kill -l 143 TERM $ kill -l 139 SEGV137 = 128 + 9 对应 KILL,143 = 128 + 15 对应 TERM,139 = 128 + 11 对应 SEGV。这个技巧在分析"进程莫名退出"时特别好用。比如你在脚本里发现某条命令失败了,$?是 139,那就基本可以断定是段错误,而不是业务逻辑返回了 139——因为正常程序的返回值范围一般是 0 到 125,超过 128 的几乎都是被信号打断的。coreutils 的kill实现里,判断条件是128 < status && status < 128 + 信号上界,所以这个减法只在合理区间内生效,不会把随便一个数字减出个离谱结果。
顺带提一句,如果你用的是 bash 内建的kill,kill -l的行为和 coreutils 版本基本一致,但在某些老版本上,大数值的反查边界会略有差异。这种细节不影响日常使用,但在写跨机器的自动化脚本时,我更愿意直接调/bin/kill或者干脆不依赖反查,改成在脚本里维护一张小映射表,可控性更强。
2.3 在脚本里用 kill -l 做参数校验
kill -l最实用的工程价值,是拿来校验用户传进来的信号名是否合法。设想你写了一个通用的启停脚本,允许使用者指定信号:
#!/usr/bin/env bash set -euo pipefail usage() { echo "用法: $0 <pid> [signal]" echo "示例: $0 12345 TERM" echo "可用信号:" kill -l | sed 's/^/ /' exit 1 } [ $# -ge 1 ] || usage pid="$1" sig="${2:-TERM}" # 用 kill -l 反查编号,失败说明信号名不合法 if ! kill -l "$sig" >/dev/null 2>&1; then echo "错误: 未知信号 '$sig'" >&2 usage fi kill -s "$sig" "$pid"这段脚本里,kill -l "$sig"的返回值就是天然校验器:合法信号名返回 0 并打印编号,非法名字返回非 0 并且什么都不打印。比你自己维护一个case白名单要可靠得多,因为不同机器支持的信号集合本来就可能不一样,让kill -l自己回答最准确。我在好几个部署脚本里都用这个套路,省去了手写信号表还要担心写漏的麻烦。
注意:
kill -l用> /dev/null屏蔽的是标准输出,不要把标准错误也屏蔽掉,否则真出问题时你连"到底哪个名字不合法"都看不到。
2.4 一个容易被忽略的差异:内建 kill 与 /bin/kill
这里必须插一段容易翻车的地方。你在交互式 shell 里敲的kill,绝大多数时候跑的是bash 内建命令,而不是/bin/kill或/usr/bin/kill。用type kill就能确认:
$ type kill kill is a shell builtin $ type -a kill kill is a shell builtin kill is /usr/bin/kill内建版本的优势是能直接吃作业号(kill %1),而且不受 PATH 影响;coreutils 版本的优势是支持-s、--signal、--这些更规范的选项,错误信息也更详细。两者的-l输出大体一致,但在一些边界上会分叉,比如对超大编号的处理、对多个 PID 同时传入时的行为。写脚本时我有个固定原则:脚本开头尽量显式定位到具体实现,要么统一用kill(依赖 bash 内建,前提是#!/usr/bin/env bash),要么统一写/bin/kill。最怕的是交互式测试时用的是内建,脚本里跑的是另一个版本,行为差异让人怀疑宿主环境有问题。
3. kill -s:用名字发信号才是正解
3.1 语法拆解与三种等价写法
kill -s的完整语义是 "send signal",它的参数就是信号本身,后面跟目标 PID:
kill -s TERM 12345 kill -s SIGTERM 12345 kill -s 15 12345三种写法发出去的是同一个信号,但工程上我强烈建议只用第一种或第二种,把数字留给"我明确知道自己在干什么"的场合。理由在前面已经说过:编号跨架构不稳定,而名字是 POSIX 层面的统一约定。
历史上还有两种老写法,你会大量见到:
kill -TERM 12345 # 短横线直接接信号名 kill -15 12345 # 短横线直接接编号这两种写法在核心上是等价的,kill会把-后面那一坨解析成信号。但它们的可读性差,而且有一个天然的坑——负号会被误认为信号。你打算给进程组发信号时写kill -12345,本意是"发给进程组 12345",结果kill把这理解成"发送编号为 12345 的信号",直接报错。正确做法是补一个--做结束符:
kill -s TERM -- -12345 # 发给进程组 12345 /bin/kill -15 -- -12345 # 同样效果,coreutils 版本--是 POSIX 通用的"选项终止符",表示后面的内容一律当作位置参数处理。这个细节看起来小,但在批量操作场景里非常关键,后面讲进程组时会展开。
3.2 信号 0:不发信号的"心跳探测"
kill -s 0是一个特别有意思的特殊值,它不发送任何信号,但会完整走一遍内核的权限检查和目标查找流程。换句话说,它就是一次"我能不能给这个 PID 发信号"的可行性测试:
$ kill -s 0 12345 && echo "进程存在且我有权限" 进程存在且我有权限 $ kill -s 0 99999 bash: kill: (99999) - No such process $ echo $? 1这个用法写进脚本里,等价于一个轻量的进程存活探针。相比ps | grep的好处是:不依赖ps的输出格式(这个格式在不同发行版上差异巨大),不会被grep自己的进程干扰(经典的grep grep误判),而且对权限的判断是内核级的。我见过太多脚本用ps aux | grep xxx | grep -v grep来判断服务是否活着,只要服务名里带点特殊字符或者机器上有个同名进程就误判了。改成基于kill -0(-0是-s 0的简写形式)的探针,误判率会低得多。
不过kill -0也有边界要心里有数。第一,它对僵尸进程也返回成功,因为僵尸进程的 PID 还挂在进程表里,只是已经不再执行了。第二,它无法防范PID 复用,一个 PID 被回收后可能被新进程占用,你探测到的"活着"其实是另一个进程。第三,在多用户机器上,如果目标进程属于别的用户且你没有 CAP_KILL 权限,kill -0返回的是Operation not permitted而不是No such process,两者要区分对待——前者说明进程在、只是你无权触碰。
3.3 给进程组发信号:负 PID 的正确打开方式
kill的 PID 参数支持负数,语义是把信号发给整个进程组,组号等于这个负数的绝对值。这是批量操作的利器,也是误伤的高发地带。
$ ps -o pid,pgid,cmd -C nginx PID PGID CMD 12033 12032 nginx: master process 12034 12032 nginx: worker process 12035 12032 nginx: worker process # 发给整个进程组 kill -s TERM -- -12032 # 给当前 shell 所在的进程组发挂起信号,慎用 kill -s TSTP -- -$$负数的意义在于:一个进程组里的所有进程会被同一份信号同时命中(内核遍历组内成员逐个投递)。对于那些 fork 出一堆 worker 的服务,你只需要拿 master 的 PGID,就能一次性收掉整棵树,不用挨个去 grep 子进程。这比pkill -f要精确,因为pkill -f匹配的是命令行字符串,很容易匹配到不该匹配的东西。
还有一个特殊的负号约定:kill -s TERM -1表示发给当前用户有权限发送的所有进程(内核会跳过 PID 1 和调用者自身)。这条命令的危险程度不用多说,我在生产环境里除了内核源码和文档,没在任何地方见过它的正当用途。真要清理"某个用户的所有进程",明文的做法是pkill -u username,至少意图清楚、命令可审。
注意:脚本里做进程组操作时,如果你想优雅地终止"我自己启动的整棵进程树",可以在
setsid或者开启作业控制(set -m)的情况下启动子进程,让它成为一个独立进程组的组长,这样后续收尾就不会误伤父 shell 所在的组。
3.4 权限边界与返回值:别把失败当成功
kill能成功发信号的前提是:调用者是 root,或者调用者的真实 UID 或有效 UID 与目标进程的相同。不满足这个条件,内核返回EPERM,kill打印Operation not permitted并返回 1。这一条在容器化环境里尤其容易踩,因为容器里经常是 root 跑主进程、非 root 跑 sidecar,或者反过来,权限模型和直觉不一致。
返回值语义很简单但必须查:成功 0,失败非 0。所以启停脚本里不能只写kill -s TERM $PID就完事,必须检查返回值,否则进程可能早就退出了、PID 早被复用了,你把信号发给了别人还不知道:
if ! kill -s TERM "$pid" 2>/dev/null; then echo "发送 TERM 到 $pid 失败,可能进程已不存在或权限不足" >&2 exit 1 fi还有一个常被误判的场景:给 PID 1 发信号。在容器里,PID 1 是内核特殊照顾的对象——如果一个信号没有在 init 里注册处理函数,内核会直接丢弃它,而不是执行默认的终止动作。这是为了防止用户空间的 init 意外死掉导致内核 panic。所以你在容器里docker stop时看到等待十秒才强杀,十有八九就是主进程以 PID 1 身份运行、又没注册SIGTERM处理函数,信号被静默忽略了。解决办法要么是用exec让真正的业务进程接管 PID 1 并且自己处理 SIGTERM,要么在启动命令前挂一个轻量 init(很多基础镜像现在都提供了--init选项)。
4. 信号发出去了,进程为什么没反应
4.1 第一步永远是确认信号"到底有没有送到"
排障时最忌讳的跳步,就是一上来就猛敲kill -9。我的固定顺序是:先确认信号是否真的送达,再确认进程是否真的收到了,最后才讨论它为什么不响应。
确认送达最直接的办法是看kill的返回值和错误信息。如果打印了No such process,说明 PID 根本不存在,问题在 PID 而不是信号;如果打印Operation not permitted,说明权限不够,得换用户或者提权,这时候你发什么信号都没用。
确认进程是否收到了,可以从/proc/<pid>/status里读到内核记账的信号掩码:
$ grep -E 'SigQ|SigPnd|ShdPnd|SigBlk|SigIgn|SigCgt' /proc/12345/status SigQ: 0/31594 SigPnd: 0000000000000000 ShdPnd: 0000000000000000 SigBlk: 0000000000010000 SigIgn: 0000000000000000 SigCgt: 0000000180004000这六个字段全是十六进制位图,最低位对应信号 1,第 N 位对应信号 N。SigBlk是被阻塞的信号,SigIgn是被忽略的,SigCgt是被捕获(注册了处理函数)的。我写了个小脚本专门解这个掩码,排查时比肉眼数位快得多:
#!/usr/bin/env python3 import sys def decode(mask_hex, label): mask = int(mask_hex, 16) hits = [] for sig in range(1, 65): if (mask >> (sig - 1)) & 1: hits.append(str(sig)) print(f"{label:8s} -> {', '.join(hits) if hits else '(空)'}") pid = sys.argv[1] fields = {} with open(f"/proc/{pid}/status") as f: for line in f: key, _, val = line.partition(":") if key in ("SigBlk", "SigIgn", "SigCgt"): fields[key] = val.strip() for key in ("SigBlk", "SigIgn", "SigCgt"): decode(fields[key], key)跑一下就能看出端倪。比如SigIgn里如果出现了 15 对应的位(第 15 位),说明这个进程明确把 SIGTERM 忽略了,你发一百次 TERM 都没用;SigBlk里有 15,说明它把 TERM 阻塞了,要等它解除阻塞才会处理;SigCgt里有 15,说明它注册了处理函数,那就要看它的处理逻辑是不是卡住了——常见的情况是处理函数里做了耗时的清理,或者等一个永远不会来的锁。
4.2 六种"杀不死"的典型情况与处置
把排障多年遇到的案例归归类,基本逃不出下面六种。我把它们整理成一张速查表,遇到时对着看能省很多时间:
| 现象 | 根因 | 判断方法 | 处置 |
|---|---|---|---|
| 发 TERM 无反应,发 KILL 才退 | 进程捕获了 TERM 但逻辑卡住 | SigCgt包含 15,日志停在清理阶段 | 先查卡在哪(gdb -p或py-spy),修逻辑;临时用 KILL 兜底 |
| 发什么信号都无反应,KILL 也不退 | 进程处于 D 状态(不可中断睡眠),卡在内核 IO | ps -o stat显示D,/proc/pid/wchan有线索 | 等 IO 返回或修底层存储/挂载问题,信号只能等 |
KILL 之后ps里还在 | 僵尸进程,进程已死但父进程没wait() | ps -o stat显示Z | 给父进程发 SIGCHLD,或干掉父进程让 init 收养回收 |
| 信号发了但目标进程是另一个 | PID 复用 | 对比kill前后的/proc/pid/cmdline或启动时间 | 用systemd、supervisor托管,避免自己管裸 PID 文件 |
容器里docker stop等到超时 | PID 1 未注册处理函数,内核丢弃未注册信号 | 容器内ps看主进程是不是 PID 1 | 用exec启动业务进程,或挂轻量 init |
| 批量发信号有一半没生效 | 标准信号不排队,被合并了 | 日志里回调次数少于发送次数 | 改用实时信号SIGRTMIN+n或加 ack 机制 |
D 状态那条值得单独说两句。进程处于TASK_UNINTERRUPTIBLE时,内核不会把它唤醒去处理信号,因为唤醒就意味着打断正在进行的关键内核操作(比如某些文件系统写入、NFS 挂载点卡住)。这时候kill -9返回 0,看起来"发送成功",但进程依然挂在进程表里,你在ps里能看到它、状态是 D、CPU 占用 0。我遇到过一次就是因为挂载点背后的存储服务失联,整批进程全卡在 D 状态,最后靠恢复存储才解开,期间任何信号操作都是心理安慰。
僵尸进程那条也有个常见误区:僵尸不是一个"还活着的进程",而是一条还没被父进程读取的退出记录。所以对它发任何信号都没有意义,它不占 CPU、不占内存(除了一个 task_struct),唯一的解法是让父进程调wait()回收,或者让父进程退出、由 init 接管回收。有些人看到僵尸就kill -9,然后发现"杀不掉",其实从一开始就搞错了对象。
4.3 写一个靠谱的优雅停机模板
知道原理之后,落地成一个可复用的模板才有价值。下面这个函数是我在多个启动脚本里用过的版本,核心逻辑是"先礼后兵":发 TERM,轮询等待,超时才上 KILL,并且全程记录耗时和结果:
#!/usr/bin/env bash set -uo pipefail graceful_stop() { local pid="$1" local timeout="${2:-30}" local waited=0 if ! kill -0 "$pid" 2>/dev/null; then echo "[stop] pid $pid 已不存在" return 0 fi echo "[stop] 向 $pid 发送 SIGTERM" kill -s TERM "$pid" 2>/dev/null || { echo "[stop] TERM 发送失败" >&2 return 1 } while [ "$waited" -lt "$timeout" ]; do if ! kill -0 "$pid" 2>/dev/null; then echo "[stop] 进程在 ${waited}s 内优雅退出" return 0 fi sleep 1 waited=$((waited + 1)) done echo "[stop] 超过 ${timeout}s 未退出,升级为 SIGKILL" >&2 kill -s KILL "$pid" 2>/dev/null || true # KILL 之后也要给内核一点时间回收 for _ in $(seq 1 5); do kill -0 "$pid" 2>/dev/null || { echo "[stop] 强制终止完成"; return 0; } sleep 1 done echo "[stop] 进程仍存在,可能处于 D 状态或已成僵尸" >&2 ps -o pid,stat,cmd -p "$pid" 2>/dev/null || true return 2 }这个模板里有几个刻意的设计,值得解释一下为什么这么做。超时用轮询而不是wait,是因为wait只能等自己的子进程,而启停脚本经常要处理"不是我启动的进程"(比如重启系统里已有的服务)。KILL 之后还要再等一轮,是因为kill -9返回成功只代表信号投递成功,不代表进程已经被回收,紧接着去检查可能会误判。最后打印ps状态,是为了在失败时直接给出判断依据——是 D 状态还是 Z 状态,一眼就能看出来,不用再回头查。把超时做成参数,是因为不同服务的清理成本差异极大:一个纯内存服务 5 秒足够,一个要刷几 GB 缓冲到磁盘的服务可能得给 60 秒,一刀切地设 30 秒迟早出事。
5. 几个我踩过的坑和实操心得
5.1 kill -9 是最贵的按键,尽量别当第一反应
我早期有个坏习惯,看到服务不响应就kill -9,快、准,屡试不爽。代价是后来做数据核对时发现了几次莫名其妙的不一致,追根溯源都是进程在写文件的中途被硬杀,缓冲区没刷、临时文件没删、锁没释放。SIGKILL绕过了一切用户态逻辑,等同于把电源线拔了,对数据库、消息队列、写日志的服务都是高风险操作。
后来我给自己定了个规矩:kill -9只能出现在两种场合——一是进程明确已经无响应且经过至少一轮 TERM 加超时等待;二是开发环境里我自己启动的、没有状态的一次性进程。生产环境里,如果 TERM 不生效,先花五分钟看SigCgt、看线程栈、看日志尾部,收益远大于省下的那五分钟。因为硬杀只是把问题从"进程不响应"变成了"数据可能不一致",后者排查成本高一个数量级。
5.2 用 STOP 和 CONT 做现场保留,比直接杀更聪明
排查线上问题时,最怕的是"杀掉之后就复现不了了"。这时候SIGSTOP比SIGKILL有用得多:
kill -s STOP 12345 # 冻结,进程不再执行任何用户态指令 # 从容地 dump 内存、抓线程栈、看 /proc/pid/maps kill -s CONT 12345 # 恢复运行SIGSTOP会把进程挂起在内核调度之外,CPU 不再分配时间片,但内存、文件描述符、socket 连接全都原封不动。你可以慢慢用gdb -pattach、用py-spy dump抓栈、用ss看连接状态,什么时候看够了再SIGCONT放它继续跑。这个手法在处理"偶发卡死"类问题时价值极高,因为卡死的现场一旦进程退出就永久丢失了。
需要留意的是,SIGSTOP对某些场景并不"干净":如果进程持有分布式锁或者正在等外部服务的响应,冻结它可能会导致上游超时、触发重试,进而把问题放大。所以冻结时间要可控,通常我会在操作前先确认这个进程在架构里的位置,确认它不是一个关键路径上的单点。
5.3 用 USR1 做日志切割时,记得先确认对方真的接了
SIGUSR1是个"自定义信号",内核不给它赋予任何业务含义,具体干什么完全由进程自己决定。nginx 用它重开日志文件,dd 用它打印进度,很多 Java 服务用它触发堆 dump。这种"约定俗成"导致一个常见故障:你以为发 USR1 会让服务切日志,结果那个服务根本没注册这个信号的处理函数,默认动作是终止进程——日志没切成,服务先挂了。
所以发SIGUSR1之前,我一定先查一下SigCgt里有没有第 10 位:
# 检查进程是否捕获了 SIGUSR1 grep SigCgt /proc/12345/status # 把输出的十六进制转换成二进制,看第 10 位是否为 1如果没捕获,那这个信号对它来说就是"终止",绝对不能拿来当通知用。这条经验我吃过一次亏——一个日志轮转脚本照着文档给服务发 USR1,换了个服务版本之后行为变了,半夜挂了一片,日志里连个像样的错误都没有,因为进程是被信号直接终止的。
5.4 别把 PID 文件当真理,进程身份要做二次校验
启停脚本普遍依赖 PID 文件,但 PID 文件是最容易过期的东西:进程崩溃了文件还在、机器重启了文件还在、容器重建了文件可能被镜像里的旧内容覆盖。基于过期 PID 去做kill,轻则报错,重则误杀一个碰巧复用了这个 PID 的新进程。
我的做法是在 kill 之前做一次身份校验,读/proc/<pid>/cmdline或者对比启动时间:
pid_from_file=$(cat /run/myapp.pid) expected="myapp-server" actual=$(tr '\0' ' ' < "/proc/$pid_from_file/cmdline" 2>/dev/null || echo "") case "$actual" in *"$expected"*) echo "身份校验通过,可以操作" ;; *) echo "PID $pid_from_file 已被其他进程占用,拒绝操作" >&2; exit 1 ;; esac想更严格一点,可以记录进程启动时刻(/proc/<pid>/stat的第 22 个字段,单位是开机后的 clock tick),在写入 PID 文件时一并存下来,操作前对比一次。这样即使 PID 被复用,启动时间也几乎不可能撞上,误杀概率降到可以忽略。这套做法在托管进程不多的小环境里显得麻烦,但只要机器上跑的东西超过十个、重启频率一高,它就值回票价了。
5.5 排查时优先看 /proc,比翻 man 手册快
最后分享一个效率习惯。新手遇到信号问题喜欢翻手册,我现在的第一反应是直接看/proc/<pid>/status和/proc/<pid>/task/<tid>/status。前者给你进程级的信号掩码,后者能定位到具体哪个线程阻塞或忽略了某个信号。多线程程序里,信号到底投递给哪个线程是由内核调度的(通常选一个没阻塞该信号的线程),如果所有线程都阻塞了它,信号就会一直 pending。这种问题在单线程视角下永远看不出来,必须下沉到线程级。
另外kill -l的输出我一般会存一份到自己的笔记里,不是为了背,而是为了在写告警规则、写监控脚本、跟别人对参数时能快速确认编号。信号这东西,名字是稳定的契约,编号是实现细节,把这两者的关系理顺了,kill这一族命令用起来就不会再有"玄学"的感觉。