卷 V · 邻里CH 21深度 21/24

信号:异步打断你的程序

信号是 Unix 里最古老、也最容易用错的机制。它的本质是:内核在你的任意两条指令之间,硬插进一次函数调用。「任意两条」这四个字,就是它所有麻烦的根源。

信号投递异步安全SIGKILLself-pipe

它不是通信,是打断

信号能传的信息量少得可怜:一个编号(外加 sigqueue 的一个整数)。你不能用它传数据。

它的价值在别处:它能在对方毫无准备的情况下强行插入执行。

看一下 Ctrl-C 的全程,这条链比大多数人想的长:

  1. 你按下 Ctrl-C,键盘中断(第 4 章)。
  2. tty 驱动认出这是 INTR 字符。
  3. 它向前台进程组的所有进程发 SIGINT。(注意是整个进程组——这就是为什么 Ctrl-C 能一次干掉整条管道。)
  4. 内核在每个目标进程的 task_struct设一个待处理位
  5. 什么也没发生。进程还在跑。
  6. 直到某个时刻,那个进程从内核态返回用户态——可能是它做完了一次系统调用,可能是被时钟中断打断后要回去。
  7. 内核在返回前检查待处理位,发现有 SIGINT
  8. 如果注册了处理器:内核篡改用户栈,让进程「返回」到处理函数而不是原来的位置。处理完再通过 sigreturn 回到真正的原位。
  9. 如果没注册:执行默认动作——终止进程。
◆ 第 4 到第 6 步解释了很多事

信号不是立刻送达的。内核只是设了个标志位,然后等你下次经过那道边界

推论一:信号有延迟。一个纯计算的死循环,要等到下一次时钟中断把它拽进内核,才会被投递信号。通常是毫秒级,感觉不出来。

推论二:★ 这就是 kill -9 杀不掉 D 状态进程的原因(第 5 章)。D 状态的进程正卡在内核里,它根本没有「返回用户态」这个时刻。标志位设了,但没人去看。

所以 SIGKILL 不是「无坚不摧」,它只是「不可被忽略、不可被捕获」。投递本身仍然需要那个时机。

为什么处理器里几乎什么都不能干

这是本章最实用的一节。

信号处理器是在你的任意两条指令之间被插进来的。这意味着——它可能打断了任何东西:

// 假设进程正在执行 malloc,走到一半:
//   已经把某个空闲块从链表上摘下来了,
//   但还没来得及把链表的指针接回去 —— 数据结构处于中间状态
//
//        ★ 就在这一刻,信号来了 ★
//
void handler(int sig) {
  printf("收到信号\n");     // ← printf 内部也会 malloc
                            //   而 malloc 的链表现在是坏的
                            //   → 死锁,或者堆损坏
}

更糟的是:如果 malloc 持有一把锁,处理器里再去 malloc 就会试图获取自己已经持有的锁——死锁。而且是那种没有任何日志、进程就那么静静挂住的死锁。

◆ 只有「异步信号安全」的函数能用

POSIX 规定了一份白名单(man 7 signal-safety),大约一百多个函数。

在名单上的write_exitsignalkillreadclose……基本都是直接对应系统调用、不带内部状态的。

不在名单上的(也就是你最想用的那些):

  • printf / fprintf——带缓冲区,会 malloc
  • malloc / 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终止 + coremmap 的文件被截断了(第 13 章)
SIGPIPE终止往读端已关的管道/socket 写(第 20 章)
SIGINT终止Ctrl-C
SIGTERM终止★ 能——优雅退出用它kill 默认发的
SIGKILL终止不能kill -9、OOM killer(第 15 章)
SIGSTOP暂停不能Ctrl-Z
SIGCHLD忽略子进程退出了(第 6 章的收尸时机)
SIGHUP终止终端断开;约定俗成用来「重新加载配置」
◆ SIGTERM 和 SIGKILL:为什么必须有两个

SIGTERM(15)请求:「请你退出。」你可以捕获它,关连接、刷缓冲、写完日志、保存状态,然后从容退出。

SIGKILL(9)处决:进程根本收不到它——内核直接把进程从调度队列里摘掉、回收资源。没有任何执行机会。

为什么 SIGKILL 必须不可捕获?因为如果它可以被捕获或忽略,那么一个恶意或有 bug 的进程就能拒绝死亡,系统失去最后的控制手段。

正确的顺序永远是先 TERM,等一会儿,再 KILL。这就是 docker stop(默认等 10 秒)和 Kubernetes 的 terminationGracePeriodSeconds(默认 30 秒)在做的事。

而这意味着一件很实际的事:你的服务必须处理 SIGTERM不处理的话,每次部署你都在被 SIGKILL——连接被硬切、事务没提交、缓冲区没刷。

⚠ 容器里 SIGTERM 收不到的两个原因

原因一:你的进程不是 PID 1。如果 ENTRYPOINT 写成了 shell 形式:

ENTRYPOINT java -jar app.jar        # ← 错:变成 /bin/sh -c "java -jar app.jar"

那么 PID 1 是 sh,你的 Java 是它的子进程。docker stopSIGTERM 发给 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++ 库、系统库)表现为一个信号——SIGSEGVSIGABRTSIGBUS

崩溃收集 SDK(Bugly、Firebase Crashlytics、breakpad)的做法就是注册这些信号的处理器,在处理器里展开调用栈、写进文件

但这里的约束极其苛刻——这一章讲的全部限制都在生效,而且叠加了两个额外难点:

  • 不能 malloc(堆可能已经被踩坏了,那正是崩溃的原因)→ 所以要预先分配好所有缓冲区。
  • 不能用普通的栈(栈可能已经溢出了)→ 所以要用 sigaltstack() 预先准备一个独立的信号栈
  • 不能 printf → 只能 write

所以这类库的核心代码看起来非常「原始」:全是预分配的静态缓冲区和裸 write不是作者不会写现代 C++,是这一章的规则不允许。

更彻底的方案是 fork 出一个子进程去 dump——子进程有干净的堆和栈,父进程只负责在处理器里 fork(这是安全的)。Google 的 breakpad 就是这么做的。

这一章的一句话

信号是内核在你任意两条指令之间硬插进来的一次函数调用,所以处理器里只能用异步信号安全的那一百多个函数——标准解法是 self-pipe,把异步信号变成同步事件接进事件循环。而信号的投递需要你「回到用户态」这个时机,这就是 kill -9 杀不掉 D 状态进程的原因。

下一章是这一卷、也是这本书主线的收束点:我们把前面所有的机制拼起来,亲手把这个骗局再演一遍