信号:异步打断你的程序
信号是 Unix 里最古老、也最容易用错的机制。它的本质是:内核在你的任意两条指令之间,硬插进一次函数调用。「任意两条」这四个字,就是它所有麻烦的根源。
它不是通信,是打断
信号能传的信息量少得可怜:一个编号(外加 sigqueue 的一个整数)。你不能用它传数据。
它的价值在别处:它能在对方毫无准备的情况下强行插入执行。
看一下 Ctrl-C 的全程,这条链比大多数人想的长:
- 你按下
Ctrl-C,键盘中断(第 4 章)。 - tty 驱动认出这是
INTR字符。 - 它向前台进程组的所有进程发
SIGINT。(注意是整个进程组——这就是为什么Ctrl-C能一次干掉整条管道。) - 内核在每个目标进程的
task_struct里设一个待处理位。 - ★ 什么也没发生。进程还在跑。
- 直到某个时刻,那个进程从内核态返回用户态——可能是它做完了一次系统调用,可能是被时钟中断打断后要回去。
- 内核在返回前检查待处理位,发现有
SIGINT。 - 如果注册了处理器:内核篡改用户栈,让进程「返回」到处理函数而不是原来的位置。处理完再通过
sigreturn回到真正的原位。 - 如果没注册:执行默认动作——终止进程。
信号不是立刻送达的。内核只是设了个标志位,然后等你下次经过那道边界。
推论一:信号有延迟。一个纯计算的死循环,要等到下一次时钟中断把它拽进内核,才会被投递信号。通常是毫秒级,感觉不出来。
推论二:★ 这就是 kill -9 杀不掉 D 状态进程的原因(第 5 章)。D 状态的进程正卡在内核里,它根本没有「返回用户态」这个时刻。标志位设了,但没人去看。
所以 SIGKILL 不是「无坚不摧」,它只是「不可被忽略、不可被捕获」。投递本身仍然需要那个时机。
为什么处理器里几乎什么都不能干
这是本章最实用的一节。
信号处理器是在你的任意两条指令之间被插进来的。这意味着——它可能打断了任何东西:
// 假设进程正在执行 malloc,走到一半:
// 已经把某个空闲块从链表上摘下来了,
// 但还没来得及把链表的指针接回去 —— 数据结构处于中间状态
//
// ★ 就在这一刻,信号来了 ★
//
void handler(int sig) {
printf("收到信号\n"); // ← printf 内部也会 malloc
// 而 malloc 的链表现在是坏的
// → 死锁,或者堆损坏
}
更糟的是:如果 malloc 持有一把锁,处理器里再去 malloc 就会试图获取自己已经持有的锁——死锁。而且是那种没有任何日志、进程就那么静静挂住的死锁。
POSIX 规定了一份白名单(man 7 signal-safety),大约一百多个函数。
在名单上的:write、_exit、signal、kill、read、close……基本都是直接对应系统调用、不带内部状态的。
不在名单上的(也就是你最想用的那些):
printf/fprintf——带缓冲区,会 mallocmalloc/free——就是它自己- 几乎所有需要加锁的东西
- 任何你自己写的、不确定内部干了什么的函数
注意 printf 不在名单上而 write 在。所以处理器里要输出,只能:
void handler(int sig) {
const char msg[] = "收到信号\n";
write(2, msg, sizeof(msg) - 1); // 直接系统调用,安全
}
丑,但这是对的。
标准解法:self-pipe
既然处理器里什么都不能干,那就什么都别干——只记一笔,回到主循环再处理。
经典手法叫 self-pipe trick:
int pipefd[2];
void handler(int sig) {
char c = (char)sig;
write(pipefd[1], &c, 1); // 唯一的动作:往管道里写一个字节
} // write 是异步信号安全的
// 主循环里:
epoll_ctl(ep, EPOLL_CTL_ADD, pipefd[0], &ev); // 把管道读端加进 epoll
while (1) {
epoll_wait(...);
// 管道可读 = 收到信号了
// ★ 现在在正常的执行上下文里,想干什么都行
}
这一招把异步的信号变成了同步的事件,接进你已有的事件循环(第 19 章)。
Linux 后来提供了更直接的 signalfd(),把信号变成一个可读的 fd,省掉了自建管道这一步——又一次「一切皆文件」(第 16 章)。
那些你该认识的信号
| 信号 | 默认动作 | 能捕获吗 | 什么时候来 |
|---|---|---|---|
SIGSEGV | 终止 + core | 能(但基本没意义) | 访问非法内存(第 11 章) |
SIGBUS | 终止 + core | 能 | mmap 的文件被截断了(第 13 章) |
SIGPIPE | ★ 终止 | 能 | 往读端已关的管道/socket 写(第 20 章) |
SIGINT | 终止 | 能 | Ctrl-C |
SIGTERM | 终止 | ★ 能——优雅退出用它 | kill 默认发的 |
SIGKILL | 终止 | ★ 不能 | kill -9、OOM killer(第 15 章) |
SIGSTOP | 暂停 | 不能 | Ctrl-Z |
SIGCHLD | 忽略 | 能 | 子进程退出了(第 6 章的收尸时机) |
SIGHUP | 终止 | 能 | 终端断开;约定俗成用来「重新加载配置」 |
SIGTERM(15)是请求:「请你退出。」你可以捕获它,关连接、刷缓冲、写完日志、保存状态,然后从容退出。
SIGKILL(9)是处决:进程根本收不到它——内核直接把进程从调度队列里摘掉、回收资源。没有任何执行机会。
为什么 SIGKILL 必须不可捕获?因为如果它可以被捕获或忽略,那么一个恶意或有 bug 的进程就能拒绝死亡,系统失去最后的控制手段。
正确的顺序永远是先 TERM,等一会儿,再 KILL。这就是 docker stop(默认等 10 秒)和 Kubernetes 的 terminationGracePeriodSeconds(默认 30 秒)在做的事。
而这意味着一件很实际的事:你的服务必须处理 SIGTERM。不处理的话,每次部署你都在被 SIGKILL——连接被硬切、事务没提交、缓冲区没刷。
原因一:你的进程不是 PID 1。如果 ENTRYPOINT 写成了 shell 形式:
ENTRYPOINT java -jar app.jar # ← 错:变成 /bin/sh -c "java -jar app.jar"
那么 PID 1 是 sh,你的 Java 是它的子进程。docker stop 把 SIGTERM 发给 PID 1,而 sh 不会转发给子进程——它自己退出,然后你的 Java 被 SIGKILL。
ENTRYPOINT ["java", "-jar", "app.jar"] # ← 对:exec 形式,java 直接是 PID 1
原因二:PID 1 的信号语义不一样。内核对 PID 1 有特殊规定:没有注册处理器的信号会被直接丢弃,而不是执行默认动作。
这是为了保护 init 不被误杀。但后果是:如果你的应用当了 PID 1 又没显式注册 SIGTERM 处理器,那个信号被静默丢掉——它不会退出,只能等超时后被 SIGKILL。
这就是为什么容器停止总是要等满那 10 秒/30 秒。解法是用 tini 当 PID 1(它同时解决第 5 章那个僵尸问题),或者在应用里显式处理信号。
「Android 上抓 native crash 的原理」
Java 层的异常有完整的堆栈,但 native 层的崩溃(JNI、C++ 库、系统库)表现为一个信号——SIGSEGV、SIGABRT、SIGBUS。
崩溃收集 SDK(Bugly、Firebase Crashlytics、breakpad)的做法就是注册这些信号的处理器,在处理器里展开调用栈、写进文件。
但这里的约束极其苛刻——这一章讲的全部限制都在生效,而且叠加了两个额外难点:
- 不能
malloc(堆可能已经被踩坏了,那正是崩溃的原因)→ 所以要预先分配好所有缓冲区。 - 不能用普通的栈(栈可能已经溢出了)→ 所以要用
sigaltstack()预先准备一个独立的信号栈。 - 不能
printf→ 只能write。
所以这类库的核心代码看起来非常「原始」:全是预分配的静态缓冲区和裸 write。不是作者不会写现代 C++,是这一章的规则不允许。
更彻底的方案是 fork 出一个子进程去 dump——子进程有干净的堆和栈,父进程只负责在处理器里 fork(这是安全的)。Google 的 breakpad 就是这么做的。
这一章的一句话
信号是内核在你任意两条指令之间硬插进来的一次函数调用,所以处理器里只能用异步信号安全的那一百多个函数——标准解法是 self-pipe,把异步信号变成同步事件接进事件循环。而信号的投递需要你「回到用户态」这个时机,这就是 kill -9 杀不掉 D 状态进程的原因。
下一章是这一卷、也是这本书主线的收束点:我们把前面所有的机制拼起来,亲手把这个骗局再演一遍。