信号:kill -9 到底做了什么

难度 ★★★☆☆ · 前置:/proc 里那些文件是谁写的 | 一句话结论:信号是一个编号,没有内容。SIGKILL 和 SIGSTOP 不可捕获,其余都能接管;信号处理函数里只有一小撮函数是安全的,所以正确做法通常是「self-pipe 加 poll」,不是一个处理函数里办事。

30 秒之后,一刀切

线上滚动发布的那天晚上我在改另一个需求,第三次告警响起来才把窗口切回来。Kubernetes 给 Pod 里的进程发 SIGTERM,等 30 秒,还没退就 SIGKILL,这套流程我入职第一周就背下来了,但我一直以为它跟手敲 kill -9 是一回事,只是先礼后兵,多给了半分钟面子,后来把这两个信号各跑了一遍才发现它们压根不在同一层,SIGTERM 要进用户态跑你的处理函数,SIGKILL 连用户态的门都不敲,一个靠你的代码收场,一个由内核直接收尸,中间没有任何商量的余地。那天把窗口切回去,日志里只剩两行。

1
2
2026-11-13 20:31:02 received SIGTERM, shutting down
2026-11-13 20:31:02 draining connections...

然后就没了,监控上这个实例有 3 个正在上传文件的请求直接 502,我去翻代码,处理函数写得很标准,标准到我盯着看了半天没找出毛病。

这一篇的 C 代码都用 gcc -O0 -o x x.c && ./x 编译,-O0 是有意的,编译器优化会把 volatile 标志位的读写改得面目全非,观察到的行为就不是你要验证的那个了。头文件缺 #include <signal.h>、#include <unistd.h>、#include <errno.h> 的照着补。

1
2
3
4
5
6
7
8
9
10
11
static volatile sig_atomic_t stop = 0;
void on_term(int sig) { stop = 1; }

int main(void) {
signal(SIGTERM, on_term);
while (!stop) {
handle_one_request(); /* 阻塞在这 */
}
cleanup();
return 0;
}

看起来没问题,主循环看到 stop 就退。

但它退不出来,handle_one_request() 卡在 accept() 里,stop 这个标志要等这个函数返回才轮得到检查,而 accept() 在没有连接进来的半夜可以睡到天荒地老,30 秒后 SIGKILL 到,一刀切,那句 draining connections 连一个字节都没来得及排出去,这就是那 3 个请求变成 502 的全部原因,代码没问题,问题在于我把一个只置标志位的处理函数当成了能打断阻塞调用的东西。

我当初把这段理解成忘了处理信号,其实正相反,我处理了,只是误以为信号能让阻塞调用自己返回,这个误会非常常见,因为大多数情况下它确实能,前提是那个调用返回 EINTR,并且你的代码真的去检查了它,而 EINTR 这个错误码本身还带着一层更深的误会,它看起来像是出错,其实内核在说你的调用没做成就被人打断了,要么重来要么收摊,你自己看着办,我第一次读到 man 2 accept 里那句若被信号中断则返回 EINTR 的时候完全没当回事,直到自己写的服务在半夜挂起才回头把它当回事(我刚入职那会儿连 EINTR 是自己的锅都不知道,看到这个错误码还以为内核在闹脾气)。

一个编号,没有内容

信号最容易被误解成一种消息,好像它跟 HTTP 请求一样带着发件人、带着原因、带着一段载荷,实际上它就是一个编号,进程收到的全部信息就是那个数字,剩下的全靠上下文自己去猜,signal.h 里那些名字是给人看的,编译器把它们统统翻译成数字,内核里跑的从头到尾只有数字,所以同一个 SIGUSR1 在不同程序里可以是重载配置也可以是导出快照,内核一概不管,它只负责把那个位翻上去,解释权在写代码的人手里,这也是为什么信号给人的感觉总是很原始,它本来就不是一套通信协议,只是一个打断。

它有三个特点。

  1. 是一个编号(1~64),不带数据。想知道为什么来,只能靠上下文。
  2. 是异步的。随时可能来,打断任何一条指令(除了极少数的原子操作)。
  3. 标准信号(1~31)不排队。同一个信号连续来两次,如果第一次还没处理完,第二次会被合并掉。

kill 最早真的只是杀进程。

后来大家发现发个信号能干别的事,这个名字就一直没改,man 2 kill 里它老老实实写着发送信号的接口,跟杀不杀没关系,所以 kill -USR1 这种用法看着别扭其实是正解,你在用一个叫 kill 的东西给进程递纸条。

1
2
3
4
5
sleep 100 &
PID=$! # 换成你自己那个进程号,别照抄别人的
kill -TERM $PID # 发 15,默认值
kill -9 $PID # 发 9
kill -l # 列出全部信号名

最常用的一批。

号 名字 默认动作 通常用来干吗
1 SIGHUP 终止 终端断开;也常被重载配置
2 SIGINT 终止 Ctrl-C
3 SIGQUIT 终止 + core Ctrl-\
9 SIGKILL 终止 不可捕获、不可忽略
11 SIGSEGV 终止 + core 段错误
13 SIGPIPE 终止 往没人读的管道或 socket 写
15 SIGTERM 终止 优雅退出的标准信号
17 SIGCHLD 忽略 子进程变了
18 SIGCONT 继续 恢复被停的进程
19 SIGSTOP 停止 不可捕获、不可忽略
20 SIGTSTP 停止 Ctrl-Z
28 SIGWINCH 忽略 窗口大小变了

只有 SIGKILL(9)和 SIGSTOP(19)不能被捕获、不能被忽略,其余全部可以被接管,这条边界画得很硬,它是内核在投递之前就判定的,不给你在用户态讨价还价的余地,所以那些在信号里写 if (sig == SIGKILL) 的代码永远不会被执行,函数压根不会被调用,这一点跟权限无关,root 也不行。

记住九和十九,剩下的都能谈。

待处理和屏蔽不是一回事

一个信号从生下来到被处理,中间要过一个很小的状态机,内核给每个进程挂两个位图,一个叫 pending,意思是已经生成但还没处理,一个叫 blocked,意思是我不想现在处理,你先挂着,这两个词中文都翻成待处理或者挂起,特别容易糊在一起,但它们说的根本不是一件事,pending 是信号已经到了,blocked 是你的进程提前说了句先别给我,前者是事实,后者是意愿,一个由别人设置,一个由你自己设置。

集合 意思
pending(待处理) 生成了,还没被处理
blocked(阻塞、屏蔽) 不想现在处理,先挂着

这张表我建议真的记一遍。

blocked 不是丢弃,是先排队,一旦解除屏蔽,pending 里那些立刻就会被处理,一个 sigprocmask 之后,原本躺在 pending 里的信号会在下一个检查点齐刷刷地涌进来(这个涌进来的时机我当初以为是立刻,其实要等下一次从内核态返回用户态,中间隔多久全看调度),所以屏蔽信号常常被用来做临界区,把可能打断我的那一小段保护起来,代价是这段时间里信号会积压,解除的时候一次性爆出来,而且积压这件事在多线程里更绕,kill 发出去的信号是给整个进程的,内核会挑一个没屏蔽它的线程去投递,你以为指定了线程,其实只是给了个候选池,这一点在写多线程服务的时候坑过我不止一次。

最容易混的是这条命令。

1
kill -STOP $(pgrep -n mysqld)     # 这是停止进程,不是屏蔽信号

SIGSTOP 是按下暂停键,进程状态变 T,屏蔽信号是另一套东西,sigprocmask 干的活,一个改变进程的调度状态,一个改变信号的投递策略,两者唯一的共同点是名字里都有一个停字,我见过有人为了不让某个信号打扰业务,跑去 kill -STOP 自己的服务进程,结果整个进程连 CPU 都不给了,那不是屏蔽,那是把自己按在地上。

流程是这样的。

图上最后那一行是重点,SIGKILL 走的是 H 这条路,根本不进用户态。

两条路:一条回到用户态,一条直接走

SIGTERM 走的是四步,中间每一步都还在进程自己的上下文里。

  1. 内核在 pending 位置位;
  2. 目标进程下次从内核态返回用户态时,内核检查 pending;
  3. 有,就把返回地址改到处理函数,把原来的寄存器压栈;
  4. 处理函数跑完,sigreturn 恢复现场,回到被打断的地方。

中间有用户态代码参与,你的函数是在进程自己的栈上跑的,跑完还得靠一次精心构造的返回把现场接回去,那个返回不是普通的函数返回,内核在栈上塞了一个跳板,处理函数一结束就跳到它那里,它再替你把寄存器原样装回去,看起来就像什么都没发生过。

SIGKILL 只有三步,而且一次都不用回到用户态。

  1. 内核在 pending 位置位;
  2. 内核直接走 do_exit() 的路径;
  3. 进程的内存、fd、task_struct 全被清理。

一次用户态都不回。

完全在内核里完成,内存表清空、fd 逐个关掉、task_struct 从哈希链上摘掉,收尾做得比任何用户态代码都干净,代价是你在离开前想说的最后一句话也一并被抹掉了。

所以 SIGKILL 更强这个理解是错的,准确的说法是它根本不给你选择权,你连说一句让我收拾一下的机会都没有,这两条路径的差别不是强度大小,是有没有你的代码在场,一旦意识到这点,很多设计上的选择就顺了,比如为什么数据库的崩溃恢复要靠日志重放而不能靠退出时刷盘,因为 SIGKILL 之后根本没有退出时。

处理函数里能干什么,限制得比想象中死

信号处理函数是在任意一条指令处被调起的,它跑的时候主流程的变量可能正处在半更新状态,一个链表可能刚删掉一半,一个缓冲区可能刚把长度改大还没拷数据,所以能调的函数被限制得很死,POSIX 专门定义了一份异步信号安全的清单,只有单子上的能在处理函数里用,单子短得让人意外,因为它的判定标准不是这个函数安不安全,而是这个函数会不会碰全局状态,只要它在内部依赖某个共享的结构,哪怕只是一把锁,就一律出局(这份清单我抄在便利贴上贴过一阵,后来发现真正需要记住的其实只有一条,别在里头 printf)。

能调 不能调
write、read、_exit printf、malloc、free
open、close、fcntl syslog、exit
sigaction、sigprocmask 任何标准库的容器操作
waitpid(带 WNOHANG) pthread_*(大部分)

printf 为什么不行,因为它内部有缓冲区、有锁、还可能顺手调一次 malloc,如果主流程正好在 printf 里被信号打断,处理函数又调 printf,锁重入要么死锁要么把半个缓冲区写花,而且这个死锁现场的 core 特别难看,两边的调用栈都停在 printf 里面,你根本分不清谁先动的手,真要打日志就用 write(2, buf, len),它是异步信号安全的,代价是你得自己拼字符串、自己算长度,不能 %d,也不能换行,所有的格式化都得手写,所以真正讲究的服务通常是让处理函数只写一个字节到一个管道里,把消息留给主循环去拼,这样连字符串都不用处理。

还有两个细节,都不大,但都是真会咬人的那种。

errno 会被覆盖,处理函数返回后,主流程那个刚失败的系统调用,errno 已经变成处理函数里最后一次调用的结果了,标准写法是先存再还。

1
2
3
4
5
void handler(int sig) {
int saved_errno = errno;
/* ... 干活 ... */
errno = saved_errno;
}

全局变量得用 volatile sig_atomic_t。

不这么写的话编译器可能把它缓存在寄存器里,主循环永远看不到变化,编译器没有义务去读一个它认为没人改过的内存,volatile 就是告诉它这行别优化,而 sig_atomic_t 保证这个读写是一条指令完成的,不会读到一半被切走(这两个词我一开始只记了前者,后来发现单靠 volatile 在多核上还是不够,sig_atomic_t 那半句才是关键)。

SA_RESTART 救了你,也埋了你

signal() 在不同 Unix 上语义不一样,有的 BSD 会重启被中断的系统调用,有的不会,所以 POSIX 干脆另起了一个 sigaction(),把要不要重启变成你显式指定的一个标志位(我当初以为 SA_RESTART 是个性能开关,名字听着像是能让系统调用跑得更快,直到把开头那个 accept 的 bug 追到这一行才明白,它的意思是让系统调用永远不返回)。

1
2
3
4
5
6
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_term;
sigemptyset(&sa.sa_mask); /* 处理期间额外屏蔽哪些 */
sa.sa_flags = SA_RESTART; /* 关键:被打断的系统调用自动重启 */
sigaction(SIGTERM, &sa, NULL);

sa_flags 里几个关键的。

标志 作用
SA_RESTART 被打断的系统调用自动重试
SA_NOCLDWAIT 子进程退出不产生僵尸(替代 SIG_IGN)
SA_NOCLDSTOP 子进程被停住时不发 SIGCHLD
SA_RESETHAND 处理一次后恢复默认动作
SA_NODEFER 处理期间不自动屏蔽自己

SA_RESTART 是双刃剑,它让 read、write 这些自动重试,程序看起来更稳,也意味着你依赖 EINTR 跳出阻塞的代码会永远不返回,开头那个 bug 就是这么来的,accept() 在 SA_RESTART 下被打断会自己重启,标志位永无被检查的机会,更麻烦的是这个行为是默认开启的,signal() 在 glibc 上就是按 SA_RESTART 实现的,你什么都没设,内核已经替你把重启打开了。

那些明明处理了信号却退不出来的进程,答案往往藏在这一行你没写过的代码里。

正确的做法有两个。

方案一是不设 SA_RESTART,在代码里自己检查 EINTR。

1
2
3
4
5
6
7
8
while (!stop) {
int c = accept(lfd, NULL, NULL);
if (c < 0) {
if (errno == EINTR) continue; /* 回头再检查 stop */
perror("accept"); break;
}
handle(c);
}

方案二,自管道,处理函数只管往一个管道里写一个字节,主循环用 select 或者 epoll 同时监听这个管道和真正的连接,信号就从异步事件变成了一个可读的 fd,整套异步信号安全的约束在这一步全部消失,剩下的都是普通代码,想怎么写就怎么写。

1
2
3
4
5
6
7
8
9
10
11
12
13
int sigpipe[2];
void on_term(int sig) {
int saved = errno;
write(sigpipe[1], "x", 1); /* 异步信号安全 */
errno = saved;
}

/* 主循环 */
struct pollfd fds[2] = {
{ .fd = lfd, .events = POLLIN },
{ .fd = sigpipe[0], .events = POLLIN },
};
poll(fds, 2, -1);

这就是 libevent、libuv、nginx 都在用的模式,它把异步的信号变成了同步的可读事件,所有清理逻辑都能写在普通代码里,不用再受异步信号安全的限制,代价只是多一个 fd 和一个字节的读写(这个模式我第一次见是在 nginx 的源码里,当时还以为是某种日志机制,找了半天没找到 write 的调用点)。

栈溢出时的信号:sigaltstack

上面这些都建立在信号送达时处理函数能跑起来这个前提上,有一种情况它跑不起来,栈本身崩了。一个递归写错的函数把栈撑爆,访问到 guard page,内核往那里投递 SIGSEGV,处理 SIGSEGV 的函数要压栈、要调 backtrace(),可栈已经没地方了,结果就是这个处理函数自己也炸,你什么都拿不到,这时候唯一的出路是给它换一块地方,sigaltstack() 干的就是这件事。

1
2
3
4
5
6
7
8
static char altstk[SIGSTKSZ];
stack_t ss = { .ss_sp = altstk, .ss_size = sizeof altstk };
sigaltstack(&ss, NULL);

struct sigaction sa = { .sa_handler = on_segv };
sa.sa_flags = SA_ONSTACK; /* 关键:这个 handler 走备用栈 */
sigemptyset(&sa.sa_mask);
sigaction(SIGSEGV, &sa, NULL);

sigaltstack() 和 SA_ONSTACK 必须成对出现,只调前者不设后者等于白准备,SIGSTKSZ 在 glibc 2.34 之后变成了运行时值,不再是编译期常量,所以别写 static char buf[SIGSTKSZ],改用 malloc 或者 sysconf(_SC_SIGSTKSZ)(这个变化我是被编译错误顶回来的,老代码升级镜像之后突然编不过,查了半天才发现头文件里那个宏变成了函数调用)。

别自己写 handler:signalfd

自管道那套已经够用了,内核还给了个更省事的,signalfd(2.6.22)。它把信号变成了一个 fd,先用 sigprocmask 把要处理的信号屏蔽掉,再用 signalfd(2) 创建一个 fd,之后只要这个 fd 可读,read 它就拿到一个 signalfd_siginfo 结构,里面是哪个信号、谁发的、带什么值,直接塞进已有的 epoll 里,一行 epoll_ctl 搞定,不需要额外的管道,也没有异步信号安全的顾虑,因为处理函数压根不存在。

代价是用 signalfd 必须先把信号屏蔽掉,而屏蔽意味着这个信号不会打断任何系统调用,也就没有 EINTR,对信号只用来通知、不用来打断的服务,这正是想要的,对需要在信号到达瞬间中断某个阻塞调用的场景,还是得用传统 handler。三个接口的关系是,sigaction 是基础,sigaltstack 解决栈崩了的边缘情况,signalfd 是把信号纳入事件循环的正规做法,现代网络服务一般用第三个,日志和崩溃收集用第二个,第一个是所有实现的地基(我自己的项目到现在还停在第一个,够用,也没出过事)。

退出这件事,六步都得走完

一个生产级服务的退出顺序大概是这样,六步看着多,其实每一步都在解决一个具体的漏单问题。

步 动作 为什么
1 从负载均衡摘掉自己 不然新请求还在来
2 停止 accept 新连接 监听 socket 关掉或不再 accept
3 通知现有连接要关了(HTTP/2 GOAWAY、TCP FIN) 让对方知道别复用
4 等手头请求跑完,或者到超时 SIGTERM 之后的宽限期
5 刷缓冲、提交事务、关连接
6 exit(0)

第 4 步是关键,宽限期要小于上游给的超时,K8s 默认 terminationGracePeriodSeconds: 30,如果你的代码等 60 秒才退,第 30 秒就被 SIGKILL 了,前面五步全白干,而且你永远等不到那句日志,因为它还没被刷出去,更阴的是第 1 步和第 2 步的顺序,先摘流量再关监听,反过来做的话会有一小段时间新连接进来了却没人 accept,那批请求同样会 5xx(我第一次配这个流程就配反了,测试环境的流量小,一直没暴露,上预发才被压出来)。

让一个信号改掉行为

信号到底能不能改行为,这个可以当面验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cat > /tmp/sigtest.sh <<'EOF'
#!/bin/bash
trap 'echo "收到 SIGTERM,但我不走"' TERM
echo "PID: $$"
while :; do sleep 1; done
EOF
chmod +x /tmp/sigtest.sh
/tmp/sigtest.sh &
PID=$!
sleep 1
kill -TERM $PID
sleep 1
ps -o pid,stat,comm -p $PID # 还活着
kill -KILL $PID
sleep 1
ps -o pid,stat,comm -p $PID # 没了

sleep 在 bash 里是子进程,trap 要等它返回才执行,这是 bash 的经典行为,wait 一下就能立刻响应。

按 Ctrl-C 试试,bash 里 wait 是可中断的,sleep 不是(这条我是被同事当场演示了一次才信的,之前一直以为 trap 会自己抢在前面跑)。

1
2
3
trap 'echo 收到' TERM
sleep 100 &
wait $!

再看 pending 是怎么积压的。

1
2
3
4
5
6
7
8
9
10
11
python3 -c "
import signal, os, time
signal.signal(signal.SIGUSR1, signal.SIG_IGN) # 忽略
signal.pthread_sigmask(signal.SIG_BLOCK, [signal.SIGUSR1])
print('PID:', os.getpid(), flush=True)
time.sleep(10)
" &
PID=$!
sleep 1
for i in 1 2 3 4 5; do kill -USR1 $PID; done
cat /proc/$PID/status | grep -i sig

SigPnd 那行会显示某个位置了 1,五次只记一次,这就是标准信号不排队,注意这里先 SIG_IGN 再 block,是为了让它留在 pending 而不被处理。

观察 EINTR。

1
2
3
4
5
6
7
8
9
python3 -c "
import signal, os, time
signal.signal(signal.SIGALRM, lambda s,f: print('alarm!', flush=True))
signal.alarm(2)
try:
time.sleep(10)
except InterruptedError:
print('sleep 被打断')
"

Python 会自动重试被中断的系统调用(PEP 475,3.5 起),所以这里看不到 InterruptedError,这个细节说明 EINTR 的处理在语言层面也能做,但底层还是 SA_RESTART 那套逻辑(我当初以为是 Python 帮我兜住了,后来读 PEP 475 才知道它是在解释器里自己重试的,跟内核那个标志位是两套平行实现,谁也没管谁)。

想看到真的 EINTR,用 C。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>

void on_alarm(int s) { write(1, "alarm!\n", 7); }

int main(void) {
struct sigaction sa = { .sa_handler = on_alarm };
sigaction(SIGALRM, &sa, NULL); /* 故意不设 SA_RESTART */
alarm(2);
char buf[16];
ssize_t n = read(0, buf, sizeof buf); /* 等 stdin */
if (n < 0 && errno == EINTR) printf("read 被 EINTR 打断\n");
return 0;
}

跑起来别输入,两秒后 read 返回 -1、EINTR。

然后看 /proc/PID/status 里的信号字段,一个进程对信号的全部态度都写在这五行里。

1
grep -E 'Sig(Q|Pnd|Blk|Ign|Cgt)' /proc/self/status
1
2
3
4
5
SigQ:   0/63201
SigPnd: 0000000000000000
SigBlk: 0000000000000000
SigIgn: 0000000000010000
SigCgt: 0000000180004000

每一列是个十六进制位掩码,第 N 位对应第 N 号信号,SigIgn 里那个 10000 是第 17 位(SIGCHLD),说明这个进程忽略了 SIGCHLD,SigCgt 非零就是装了处理函数,想知道某个信号有没有被接管,看这一位就够了,这套位运算我一开始老数错位,后来干脆从右边往左边数,第几位就数第几个十六进制字符,SigQ 那行的 0/63201 是队列里待处理的条数和上限,实时信号才会真的用上这个队列,普通信号到不了这里。

-9 不是更用力,是另一套机制

kill -9 比 kill -15 强,这句话我听了不下十遍,其实它们不是一个力度,是两套机制。这两套机制的分歧只有一句话。-15 给了进程收尾的机会,-9 直接抹掉,能用 -15 就别用 -9;至于 SIGKILL 能被捕获只是需要 root,完全不能,SIGKILL 和 SIGSTOP 在任何权限下都不可捕获,那些说 sudo kill -9 效果不一样的帖子,是把权限和可捕获性混成了一件事。

下一段是最容易记岔的一段。信号会排队,标准信号(1~31)不排队,同一个信号连续来会被合并,实时信号(32~64,SIGRTMIN~SIGRTMAX)才排队,这也是 SIGCHLD 每死一个就发一次这个说法站不住的原因,它不排队,可能只发一次,处理函数里必须循环 waitpid 才能收干净。处理函数里可以随便写,只能调异步信号安全的函数,printf、malloc、exit 都不行,这条我第一次读到的时候没当回事,直到有次在 handler 里 printf 打了行日志,压力测试下偶发挂死,core 里看到的是 malloc 里的锁(那次之后我把所有 handler 都改成了只置一个标志位)。

编号、异步、不排队

信号是个编号,异步到达,标准信号不排队,只有 SIGKILL 和 SIGSTOP 不可捕获,因为它们在内核里就直接走完了,压根不回用户态,SIGTERM 想用得好,处理函数里只能调异步信号安全的函数,而 SA_RESTART 会让阻塞调用永不被打断,所以要么检查 EINTR,要么用自管道把它转成一个可读事件,宽限期还得小于上游给的时间,否则前面做的所有事都在第 30 秒归零。

这个体验后来在另一件事上又重演了一次,nohup ./server & 看着后台跑得好好的,ssh 一断,人就没了。