目录

Linux-29 信号:背着 40 年债的机制

目录

前一篇的 futex 把「等某个内存值变化」变成了可控的同步协议。这一篇看 Linux 里更古老、也更不规整的一套通知机制:信号(signal)

信号最初诞生于 Unix 早期,目标是让终端、内核和另一个进程能对正在运行的程序说一句极短的话:停止、继续、退出、子进程结束、定时器到了。它的代价是:这句话可以在任意指令之间插进来,而且早年的信号甚至不排队。

今天几乎每个服务都要处理 SIGTERM,每个 shell 都依赖 SIGINTSIGCHLD,每个网络程序都可能遇到 EINTR。但「捕获一个信号」远不等于调用一次普通回调。

先看五个问题:

  1. 信号为什么不是函数调用?它在程序执行的哪个时刻真正运行?
  2. 普通信号为什么会丢,而实时信号为什么不会?
  3. 一个多线程进程收到 SIGTERM 后,究竟由哪个线程执行处理函数?
  4. read 被信号打断为什么会返回 EINTRSA_RESTART 为什么又不能彻底解决问题?
  5. Go 的 signal.Notify 如何把异步信号变成可 select 的 channel 事件?容器里的 PID 1 又为什么特别容易出坑?

1. 信号是一种异步控制流

1.1 不是“另起一个回调线程”

普通函数调用有明确的控制流:当前函数执行到 call,压栈,跳转,返回。信号完全不同:

正常执行:
    instruction A -> instruction B -> instruction C -> ...

信号到达后:
    instruction A -> instruction B -> [内核选择安全返回点]
                                    -> handler()
                                    -> 回到原先上下文继续

处理函数通常在被选中的目标线程上下文中运行,不创建一个专门线程。内核保存该线程被中断时的寄存器、栈和信号掩码,修改用户态返回现场;线程从内核返回用户态时,先执行用户注册的 handler,handler 返回后再恢复原上下文。

所以 signal handler 本质上是一次异步插入的控制流跳转。它可以打断任何普通代码,这决定了它不能随意调用大多数库函数,也不能假设某个全局状态正好一致。

1.2 从产生到处理:四个阶段

把信号分成四个阶段,很多混乱会消失:

① generation(产生)
   kill(2)、终端 Ctrl-C、硬件异常、定时器、子进程退出、内核事件

② pending(挂起)
   信号已到达目标进程/线程,但暂时不能递送

③ delivery(递送)
   内核选择一个目标线程,在合适的用户态返回点安排默认动作或 handler

④ disposition(处置)
   默认终止/忽略/停止/继续,或执行用户安装的 handler

「发送信号成功」通常只意味着 ① 成功,不代表对方已经做完 ④。kill(pid, SIGTERM) 返回 0,表示内核接受了请求;目标进程可能还没获得 CPU、信号可能被阻塞,或 handler 正在排队等待执行。

1.3 信号从哪里来

来源 例子 典型信号
另一个进程 kill, sigqueue, pkill SIGTERM, SIGUSR1
终端驱动 Ctrl-C、Ctrl-Z、终端断开 SIGINT, SIGTSTP, SIGHUP
内核异常 非法地址、除零、非法指令 SIGSEGV, SIGFPE, SIGILL
子进程状态变化 exit、stop、continue SIGCHLD
定时器 alarm, POSIX timer SIGALRM, realtime signal
资源限制 CPU 时间、文件大小 SIGXCPU, SIGXFSZ
管道/socket 向无人读取的一端写 SIGPIPE

同一个 signal number 可以有不同来源。SIGSEGV 既可能来自真实硬件页错误,也可能来自另一个进程的 kill -SEGV;诊断时要结合 siginfo_tsi_code 判断。

1.4 默认动作有四类

Term:终止进程
Core:终止并尝试产生 core dump
Stop:停止进程(不能被捕获或忽略的 SIGSTOP)
Cont:继续已停止进程(SIGCONT)
Ign :默认忽略
kill -l
# 列出当前平台的信号编号和名字

man 7 signal
# 最重要的完整参考:默认动作、可捕获性、语义差异

信号编号不是跨 Unix 的稳定 ABI;Linux、macOS、BSD 的编号和实时信号范围都可能不同。应用代码应使用 SIGTERM 等符号,不应硬编码数字。


2. 40 年债:不可靠信号为什么会丢

2.1 传统信号是“位”,不是队列

传统 Unix 信号设计中,每个信号类型在 pending 集合里只占一个 bit:

SIGUSR1 pending bit:0 / 1

发送 SIGUSR1 三次:
0 -> 1 -> 1 -> 1

接收方解除阻塞后:
只会看到一次 SIGUSR1

这叫 coalescing(合并)。它不是内核偶然丢消息,而是传统信号从设计上只表达「这种事情至少发生过一次」,不表达发生了几次。

# 示意:连续发很多次,handler 次数通常远少于发送次数
for i in $(seq 1 1000); do kill -USR1 "$PID"; done

因此绝不能用普通 SIGUSR1 实现「每个任务一条通知」或「准确计数 1000 次事件」。它适合幂等的状态通知:配置需要重载、该退出了、子进程状态可能变化。

2.2 早期 handler 还有更糟的竞态

早期 System V 风格信号递送时会把 handler 重置为默认动作。程序必须在 handler 内重新安装;在「handler 返回」与「重新安装」的窗口中,同类信号可能触发默认动作。这就是历史上所谓“不可靠信号”的重要来源。

BSD 后来引入了保留 handler、自动阻塞正在处理的信号等更合理语义。POSIX 用 sigaction() 统一了现代接口。今天不要用历史接口 signal() 写严肃程序:不同 libc/标准模式下它可能映射到不同语义。

// 不推荐:历史语义与可移植性陷阱
signal(SIGTERM, handler);

// 推荐:明确 flags、掩码和 handler 形式
struct sigaction sa = {0};
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGTERM, &sa, NULL);

2.3 实时信号:队列化的修补

POSIX realtime signals 通常从 SIGRTMINSIGRTMAX(Linux 常见 34~64,实际范围受 libc/线程库影响)。它们有两个关键不同:

  1. 每次发送会排队,不与同号信号合并
  2. 可携带一个整数或指针值(sigqueue()sigval
union sigval value;
value.sival_int = 42;
sigqueue(pid, SIGRTMIN, value);

同类实时信号按发送顺序递送;不同实时信号之间通常按编号优先级递送。排队需要内核资源,因此受 RLIMIT_SIGPENDING 等限制,满了 sigqueue 可失败为 EAGAIN

实时信号修复了“不能计数”的问题,却没有修复信号最根本的限制:handler 仍然异步执行,仍然只能调用 async-signal-safe 函数。高吞吐消息传递应使用 socket、pipe、eventfd、共享内存队列或 io_uring,不该把 RT signal 当 RPC 总线。

2.4 SIGCHLD 是合并语义的典型例子

多个子进程可以在同一时间段退出,父进程也许只收到一个 SIGCHLD。正确 handler 不能只 waitpid 一次:

static void reap_children(int signo) {
    int saved_errno = errno;
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        // 回收所有已经退出的子进程
    }
    errno = saved_errno;
}

SIGCHLD 只是“至少有一个子进程状态变了”的提示;waitpid 才是状态的权威来源。这是处理传统信号的通用模型:把信号当作唤醒,真实状态从可查询的内核对象重新读取。


3. 信号掩码:能延后,不能处理掉

3.1 block、pending 与 ignore 不同

每个线程都有一份 signal mask,决定哪些可递送信号暂时被阻塞:

未阻塞 + 未忽略:到达后可被递送
阻塞:            到达后标为 pending,解除阻塞后再递送
忽略:            通常直接丢弃,不进入可观察的处理路径
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGTERM);
pthread_sigmask(SIG_BLOCK, &set, NULL);

// critical section:SIGTERM 不会在这里运行 handler

pthread_sigmask(SIG_UNBLOCK, &set, NULL);

不能阻塞、捕获或忽略:SIGKILLSIGSTOP。这是内核留给管理员和作业控制的最后控制权;若进程能屏蔽 SIGKILL,失控进程就无法收拾。

3.2 mask 是线程属性,不是进程全局属性

单线程时代「进程屏蔽信号」的说法还勉强可用。在线程模型中,mask 属于线程

同一进程:
T1 mask:block SIGTERM
T2 mask:allow SIGTERM

进程定向 SIGTERM 到达
-> 内核可以选择 T2 递送

新线程通常继承创建它的线程的 mask。因此常见可靠模式是:主线程在创建 worker 前先 block 相关信号,让所有线程都继承 block;再由一个专门线程用 sigwaitinfo() 同步消费。这会在第 6 章展开。

3.3 sigprocmaskpthread_sigmask

在多线程程序中,用 pthread_sigmask() 明确表达线程语义。Linux 上 sigprocmask() 也会作用于调用线程,但 POSIX 对多线程程序推荐 pthread 版本,避免可移植性和意图不清的问题。

# 查看进程状态中的屏蔽/挂起/忽略/捕获信号集合
cat /proc/"$PID"/status | grep '^Sig'
# SigPnd:线程私有 pending
# ShdPnd:进程共享 pending
# SigBlk:调用线程 mask(/proc/PID/status 视图)
# SigIgn:被忽略
# SigCgt:安装了 handler

这几个字段是位图十六进制表示。要按线程看,用 /proc/$PID/task/$TID/status;不要把一个主线程的 SigBlk 当成整个多线程进程的完整真相。


4. 进程与线程:信号究竟交给谁

4.1 两类来源,两套选择规则

Linux 区分:

process-directed(进程定向):
  kill(pid, sig)、终端 Ctrl-C、PID 1 收到 SIGTERM
  -> 内核从“未阻塞此信号”的任意线程中选择一个递送

thread-directed(线程定向):
  pthread_kill(thread, sig)、硬件同步异常(如某线程除零)
  -> 必须递送给那个指定/触发异常的线程

所以 kill(getpid(), SIGUSR1) 不保证当前线程处理;pthread_kill(pthread_self(), SIGUSR1) 才是明确线程定向。后者也不是“安全回调”:目标线程仍会异步进入 handler。

4.2 同步异常不能随便挪给别的线程

SIGSEGVSIGFPESIGILL 常来自一条具体指令的同步异常。它们必须在触发线程上处理,因为恢复/终止都依赖该线程的寄存器上下文。

不要把 SIGSEGV 当普通错误恢复机制。C/C++ 程序发生未定义行为后,handler 即使 longjmp 回去也可能带着被破坏的锁、栈和运行时状态继续执行。诊断通常应该让 core dump 留下现场:

ulimit -c unlimited
cat /proc/sys/kernel/core_pattern

4.3 多线程服务的反模式

worker A、B、C 都未屏蔽 SIGTERM
主线程安装 handler

SIGTERM 到达
-> 可能在 A 的任何普通代码中执行 handler
-> handler 改全局 shutdown 标志、调用复杂库、抢锁
-> 行为时序不可控,且 handler 可能与 A 已持有的锁冲突

正确方向不是“写更聪明的 handler”,而是减少 handler:所有线程 block,专用管理线程同步等待,并把真正工作交回正常控制流。


5. handler 的铁律:只做 async-signal-safe 的事

5.1 为什么 printf 都不安全

信号可能刚好打断线程在 mallocprintffprintfpthread_mutex_lock 或 Go runtime 内部执行时。handler 再调用同一函数,可能拿到被自己打断前已持有的内部锁:

主程序正在 printf
-> libc stdio lock 已持有
-> SIGTERM handler 被异步调用
-> handler 里再次 printf
-> 等自己先前持有的锁 -> 死锁

malloc/free 同理。信号安全不是函数“线程安全”就够了;线程安全函数可以靠锁保护,handler 的问题是重入到被中断代码的同一线程

POSIX 定义了一小组 async-signal-safe 函数。常见安全成员包括:

  • _exit
  • write
  • kill
  • sigaction
  • sigprocmask
  • waitpid(按规范条件)

完整名单以 man 7 signal-safety 为准。printfmallocfreesyslog、大多数 pthread API 都不在安全名单里。

5.2 最小 handler:标志或 self-pipe

最小策略是设置 volatile sig_atomic_t 标志:

static volatile sig_atomic_t stop = 0;

static void on_term(int signo) {
    (void)signo;
    stop = 1;
}

int main(void) {
    install_handler();
    while (!stop) {
        // 正常控制流中检查并退出
    }
}

sig_atomic_t 保证单次读写不会撕裂;它不自动解决复杂内存同步和多线程可见性。在多线程程序中,更可靠的做法是专用 sigwait 线程,或在 handler 里仅向 pipe 写一个字节唤醒事件循环。

static int signal_pipe[2];

static void on_term(int signo) {
    unsigned char b = (unsigned char)signo;
    ssize_t ignored = write(signal_pipe[1], &b, 1); // write 是 async-signal-safe
    (void)ignored;
}

主循环把 pipe 的读端纳入 poll/epoll。这是 self-pipe trick:把不适合在 handler 里做的工作转换为正常事件循环中的可读事件。

注意 pipe 可能满,handler 不能阻塞;写端要设为 nonblocking,并允许丢弃重复通知。既然传统信号本来就合并,通常只需要知道“有信号来了”。

5.3 handler 还应保存 errno

handler 调用的安全函数也可能修改 errno,覆盖被打断代码的错误状态。简单 handler 通常保存并恢复它:

static void on_chld(int signo) {
    int saved = errno;
    (void)signo;
    while (waitpid(-1, NULL, WNOHANG) > 0) {}
    errno = saved;
}

但这依旧不应成为复杂业务逻辑的入口。handler 只应完成不可避免的最小转发或标记。


6. EINTR 与 SA_RESTART:系统调用为什么会“无故失败”

6.1 被打断的阻塞调用

线程在 read()accept()waitpid()nanosleep()poll() 等调用里阻塞时,若收到一个有 handler 的信号,内核要选择:handler 执行后,原调用是否继续等待?

传统语义是返回 -1 并置 errno=EINTR

ssize_t n;
do {
    n = read(fd, buf, sizeof buf);
} while (n < 0 && errno == EINTR);

EINTR 不是 IO 出错;它表示「你等待时发生了异步控制流,调用没有按原语义完成」。调用者必须决定重试、退出、处理取消,还是更新 timeout。

6.2 SA_RESTART 不是万能修复

安装 handler 时指定 SA_RESTART,内核会让部分可重启的系统调用在 handler 返回后自动继续,而不是暴露 EINTR

sa.sa_flags = SA_RESTART;
sigaction(SIGTERM, &sa, NULL);

但不能得出“以后不用处理 EINTR”。原因:

  1. 不是所有 syscall 都可重启;例如带 timeout 的等待、某些 socket/多路复用调用有特殊规则
  2. 同一调用的可重启性可能依赖文件类型、是否已传输部分数据、内核版本
  3. 即使可重启,应用有时希望信号尽快取消等待,而不是继续卡住
  4. 库和语言运行时可能自行转换、屏蔽或重试信号

稳妥原则是:任何可能阻塞的 POSIX 调用都按文档处理 EINTR,同时明确你的取消语义。不能无脑 while (EINTR) retry,例如 nanosleep 需要用剩余时间重算;带绝对 deadline 的循环通常更不易累积误差。

6.3 timeout + EINTR 的常见 bug

// 错误示意:每次 EINTR 都重新给 5 秒,可能永远超时不了
for (;;) {
    struct timeval tv = {.tv_sec = 5};
    int n = select(nfds, &rfds, NULL, NULL, &tv);
    if (n < 0 && errno == EINTR)
        continue;
    break;
}

正确做法以单调时钟计算绝对 deadline,每次被打断后重新计算剩余时间。select 还会修改 fd_set 和 timeout,重试前必须重新初始化。

deadline = monotonic_now + 5s
loop:
  remaining = deadline - monotonic_now
  if remaining <= 0: timeout
  rebuild fd_set
  select(..., remaining)
  if EINTR: goto loop

6.4 SIGPIPE:写失败为什么还能杀进程

向 pipe 或 socket 写数据时,如果所有读端都已关闭,内核通常同时:

  1. write() 返回 EPIPE
  2. 产生默认动作为终止的 SIGPIPE

若不处理,进程可能在来得及检查 EPIPE 前被杀掉。网络服务常见做法:

signal(SIGPIPE, SIG_IGN); // 示意;严肃代码用 sigaction

或在 socket 上使用 MSG_NOSIGNAL(Linux)/SO_NOSIGPIPE(部分 BSD)避免生成 SIGPIPE。忽略 SIGPIPE 后仍必须处理 writeEPIPE,它代表对端连接已断。

Go runtime 默认会对 SIGPIPE 做符合网络服务预期的处理;但 cgo 或调用外部库时仍应理解底层语义,而不是依赖偶然行为。


7. 用同步接口驯服异步信号

7.1 sigwaitinfo:推荐的多线程模型

对服务管理类信号(SIGTERMSIGINTSIGHUPSIGUSR1),最干净的 POSIX 模式是:

主线程:block 管理信号
  -> 创建所有 worker,worker 继承 block mask
  -> 创建 signal thread

signal thread:sigwaitinfo(set)
  -> 同步返回一个信号编号
  -> 在正常线程上下文执行 shutdown/reload 工作
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGTERM);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGHUP);
pthread_sigmask(SIG_BLOCK, &set, NULL);

// 创建 workers 和 signal thread;它们都会继承 block mask

for (;;) {
    siginfo_t info;
    int sig = sigwaitinfo(&set, &info);
    if (sig == SIGTERM || sig == SIGINT) {
        begin_graceful_shutdown(); // 正常线程上下文,可加锁、写日志
        break;
    }
    if (sig == SIGHUP)
        reload_config();
}

这里没有用户 handler,因此也没有 async-signal-safe 限制。信号仍是内核异步产生的,但应用把它接收为同步阻塞调用的返回值。

不要对 SIGSEGVSIGFPE 这类同步异常用 sigwait:它们属于触发线程,不能按服务管理事件转交。

7.2 signalfd:让信号成为一个 fd

Linux 专有的 signalfd() 更适合 epoll 事件循环:

sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGTERM);
sigaddset(&set, SIGHUP);
pthread_sigmask(SIG_BLOCK, &set, NULL);

int sfd = signalfd(-1, &set, SFD_NONBLOCK | SFD_CLOEXEC);
// 把 sfd 加进 epoll

当被阻塞集合里的信号到达,sfd 变为可读,读取 struct signalfd_siginfo 可得到 sender PID、UID、信号号等信息。

epoll 等待:
  socket 可读    -> 处理请求
  timerfd 可读   -> 处理定时任务
  signalfd 可读  -> reload / shutdown

优点是统一事件模型、没有异步 handler、可以读取排队的实时信号元数据。限制是 Linux 专有,并且所有线程仍应阻塞该集合,否则信号可能先被某个未屏蔽线程按普通方式递送。

7.3 self-pipe、sigwait、signalfd 怎么选

方案 适用 优点 限制
最小 handler + 标志 单线程/简单 CLI 简单 不易唤醒阻塞事件循环
self-pipe 可移植事件循环 handler 只 write,兼容 poll/epoll 要处理 pipe 满和通知合并
sigwaitinfo 多线程服务 完全同步,无 handler 限制 要在创建线程前统一 block
signalfd Linux epoll 服务 信号成为 fd,事件循环统一 Linux 专有,mask 管理仍关键

它们共同原则是:不要在 signal handler 里执行业务逻辑。


8. Go:signal.Notify 如何把信号变成 channel

8.1 os/signal 的接口语义

Go 用 channel 暴露信号:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

<-ctx.Done()
// 在正常 goroutine 上执行优雅退出

或显式 channel:

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)

for sig := range sigCh {
    switch sig {
    case syscall.SIGHUP:
        reload()
    case syscall.SIGTERM:
        shutdown()
        return
    }
}

这是比 C handler 安全得多的 API:接收 channel 的 goroutine 在正常 Go 调度上下文中运行,可以获取 mutex、写日志、调用网络 API。

8.2 channel 缓冲不是可选装饰

文档建议 Notify 用带缓冲 channel,至少容量 1:

sigCh := make(chan os.Signal, 1)

信号到达时,Go runtime 不能为了等接收 goroutine 而阻塞在异步信号路径上。如果 channel 满,通知可能被丢弃。普通 Unix 信号本来就合并,因此大多数 shutdown 场景只需要“至少收到一次”;容量 1 正好表达这个语义。

不要写:

sigCh := make(chan os.Signal) // 无缓冲,容易错过通知
signal.Notify(sigCh, syscall.SIGTERM)

也不要把容量加到很大后假定记录了每一次普通信号。需要精确事件计数时,使用 socket、队列、共享状态或实时信号的专门设计。

8.3 NotifyContext 的第二次 Ctrl-C 语义

signal.NotifyContext 返回的 stop() 不只是资源清理。调用后,它取消对指定信号的通知并恢复默认/原有行为:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

<-ctx.Done()
stop() // 优雅退出已开始;再次 Ctrl-C 可走默认强制终止
runGracefulShutdown()

这避免程序在优雅关闭卡住时,用户连续按 Ctrl-C 却永远只能等。具体信号恢复语义仍要结合程序是否有其他 Notify 注册者。

8.4 runtime 对同步故障信号的特殊处理

Go runtime 使用 SIGSEGVSIGBUS 等实现内存访问故障、栈增长和 panic/crash 语义的一部分。应用不应随意用 signal.Notify 处理这些信号来“捕获所有崩溃”;这可能破坏 runtime 的预期行为。

处理服务生命周期优先选择:os.InterruptSIGTERMSIGHUP、必要时 SIGUSR1/2。对 core dump、panic 和诊断信号,要按 Go 版本与 runtime 文档验证后再做定制。

8.5 Go 网络服务的优雅退出骨架

func main() {
    srv := &http.Server{Addr: ":8080", Handler: handler()}

    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    <-ctx.Done()
    stop() // 后续 Ctrl-C / SIGTERM 可恢复默认行为

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Printf("graceful shutdown failed: %v", err)
    }
}

这里信号只负责触发 cancellation;HTTP server 的停止、连接 drain 和 deadline 都在正常 goroutine 控制流里完成。Shutdown 超时后的兜底策略要由业务明确:记录错误、强制 Close,或交给编排器最终 SIGKILL


9. 容器里的 PID 1:信号与僵尸进程的双重陷阱

9.1 PID 1 的默认信号语义不同

Linux 对 PID namespace 中 PID 1 有特殊保护:某些默认动作的终止信号若没有显式 handler,可能不会按普通进程的方式终止 init。这是为了避免系统 init 被意外杀死。容器的主进程恰好常常是该 namespace 的 PID 1,于是一个“平时裸跑没问题”的程序进入容器后可能对 SIGTERM 反应异常。

工程结论不是背具体例外表,而是:容器主进程必须显式设计 SIGTERM/SIGINT 的生命周期处理,并在目标运行环境验证。 不要依赖默认动作。

9.2 shell 作为 entrypoint 会吞/错转发信号

# 不推荐:shell 是 PID 1,可能不转发信号给 app
CMD my-server --config /etc/app.yaml

# 推荐 exec form:应用直接成为 PID 1
CMD ["/usr/local/bin/my-server", "--config", "/etc/app.yaml"]

如果必须用 shell 做初始化,最后用 exec 替换 shell:

#!/bin/sh
prepare_runtime
exec /usr/local/bin/my-server "$@"

否则 docker stop 发送给 PID 1 的 SIGTERM 可能只到 shell,应用继续运行;编排器超时后发送 SIGKILL,优雅退出完全失效。

9.3 PID 1 还必须回收孤儿子进程

子进程退出后,父进程必须 wait 才能释放其进程表项。容器 PID 1 若启动子进程却不回收,孤儿最终会被 PID 1 收养并可能积累 zombie。

ps -eo pid,ppid,stat,comm | awk '$3 ~ /^Z/'

应用不适合承担 init 职责时,可使用极小 init(如 Docker --init / tini)负责信号转发和 reaping;但它不能替代应用自己的 shutdown 协议。

9.4 编排器的终止窗口

Kubernetes 通常流程是:从 Service endpoints 摘除/开始终止 → 向容器 PID 1 发 SIGTERM → 等 terminationGracePeriodSeconds → 超时后 SIGKILL。实际时序还受 preStop hook、负载均衡收敛和 sidecar 影响。

因此服务的 graceful shutdown 至少要:

  1. 收到 SIGTERM 后停止接收新工作或返回 readiness false
  2. 给在途请求、消息 ack、事务提交一个明确 deadline
  3. 在 grace period 内退出
  4. 能承受 SIGKILL 随时发生:持久化操作要有崩溃一致性

信号只是终止协议的起点,不是完整的优雅关闭方案。


10. 线上排查:信号发生了什么

10.1 先确认进程有没有活着、有没有收到

# 进程是否存在;不会真的发送信号
kill -0 "$PID"

# 查看状态、信号掩码与 handler 集合
cat /proc/"$PID"/status | grep -E '^(State|SigPnd|ShdPnd|SigBlk|SigIgn|SigCgt)'

# 每个线程的状态与 mask
for f in /proc/"$PID"/task/*/status; do
  tid=${f%/status}; tid=${tid##*/}
  printf 'TID=%s ' "$tid"
  grep -E '^(Name|State|SigBlk)' "$f" | tr '\n' ' '
  printf '\n'
done

# 某个信号当前 disposition(GNU procps)
ps -o pid,blocked,ignored,caught,comm -p "$PID"

kill -0 成功不表示进程健康、也不表示信号可递送;它只验证 PID 存在且权限允许。SIGKILL 能杀掉进程也不能证明 SIGTERM handler 正确,只能说明内核最后控制权仍在。

10.2 看信号是否打断了系统调用

# 短时附着,观察 EINTR、restart_syscall 和正在等待的调用
sudo strace -ff -ttT -p "$PID" \
  -e trace=read,write,accept,accept4,poll,ppoll,epoll_wait,wait4,restart_syscall

# 在另一终端谨慎发送测试信号
kill -HUP "$PID"

可能看到:

epoll_wait(... <unfinished ...>
--- SIGHUP {si_signo=SIGHUP, ...} ---
epoll_wait(...) = -1 EINTR (Interrupted system call)

或系统调用在 handler 后被内核重启。strace 会显著改变时序,尤其是多线程高并发服务,只适合低频复现和短时现场确认。

10.3 core dump 与崩溃信号

ulimit -c
cat /proc/sys/kernel/core_pattern
coredumpctl list 2>/dev/null
coredumpctl debug "$PID" 2>/dev/null

# systemd 服务的最近退出原因
systemctl status my-service.service
journalctl -u my-service.service -b

退出码 128 + signal number 常提示被信号终止,例如 shell 中 143 常表示 SIGTERM(128+15),137 常表示 SIGKILL(128+9)。但在容器平台上 137 不等于“内核一定 OOM kill”:也可能是终止窗口到期后编排器发送的 SIGKILL。要结合 cgroup memory.events、Pod events 与日志时间线判断。

10.4 一套服务信号排查顺序

① 确认谁是 PID 1、谁是真正业务进程
   ps / 容器 entrypoint / 是否 shell 包了一层

② 确认发送给谁、进程是否有权限和存活
   kill -0;记录 PID namespace 与 sender

③ 看 disposition 与 mask
   /proc/PID/status;逐 TID 检查 SigBlk

④ 区分“没有处理”与“已开始但卡住”
   日志、goroutine/thread dump、strace、优雅退出阶段指标

⑤ 检查编排器 deadline
   SIGTERM 时间、readiness 摘除、grace period、SIGKILL 时间

⑥ 用可重复的本地/预发实验验证
   不要在生产用 SIGKILL 代替对 shutdown 协议的测试

11. 常用命令知识点扩展

11.1 信号命令

短参 长参 英文全称 作用
kill -s TERM kill --signal TERM signal 按名字发送信号
kill -l kill --list list signals 列出信号名称和编号
pkill -f PATTERN pkill --full PATTERN full command line 按完整命令行匹配进程后发送信号
pgrep -a pgrep --list-full list full 显示 PID 和完整命令行
ps -o ps --format output format 输出 stat、blocked、caught 等字段
strace -f strace --follow-forks follow forks 跟踪线程/子进程
strace -e strace --trace=EXPR trace expression 过滤目标系统调用

场景配方:

# 优雅停止,先 TERM,确认超时策略后才考虑 KILL
kill -TERM "$PID"

# 给整个进程组发送 Ctrl-C 语义的信号(负号表示 PGID)
kill -INT -- -"$PGID"

# 先列出再精确匹配,避免 pkill 误杀
pgrep -af 'my-server --config'

kill -9SIGKILL,不能被捕获、阻塞、清理资源或执行 finally/defer。它适合无响应进程的最后手段,不是服务停机的正常路径。

11.2 信号选择表

目标 常用信号 说明
请求优雅退出 SIGTERM 编排器和服务管理器的常用默认
终端用户中断 SIGINT Ctrl-C,CLI 通常与 TERM 同样处理
重新加载配置 SIGHUP 约定俗成,不是内核自动 reload
用户自定义动作 SIGUSR1/2 必须记录并维护自己定义的语义
强制终止 SIGKILL 无法处理,最后手段
停止/继续作业 SIGSTOP / SIGCONT 作业控制与调试
子进程状态提示 SIGCHLD 必须用 waitpid 循环回收

不要把信号当版本化 API。它没有 payload schema、确认、重试、流控和审计;服务间控制应优先 HTTP/gRPC/消息队列或管理平面。


12. 面试题

Q:信号为什么不是普通函数回调?

函数回调由当前控制流在确定位置发起;信号由内核异步产生,通常在目标线程从内核返回用户态的安全点修改执行现场,handler 在被打断线程的上下文和栈上执行。它可能打断任意普通代码,因此不能假设库内部状态一致,也只能调用 async-signal-safe 函数。把 handler 当普通回调会导致重入死锁和内存损坏。

Q:传统信号为什么会丢?

传统非实时信号在 pending 集合中通常每种只占一个 bit;同一信号在已挂起时再来多次仍只是 bit=1,解除阻塞后只递送一次。这是 coalescing 的设计语义,表示“至少发生过一次”,不是可靠消息队列。SIGCHLD handler 要循环 waitpid,普通 reload/terminate 必须设计成幂等。

Q:实时信号解决了什么,为什么不拿它做消息队列?

实时信号按实例排队,可经 sigqueue 携带小 payload,同号信号保持发送顺序,解决普通信号合并和无 payload 的问题。但它仍是异步信号机制,handler 规则依旧严格,队列受资源上限约束,缺少流控、确认和丰富数据模型。高吞吐事件应用 socket、pipe、eventfd 或共享内存队列。

Q:多线程进程调用 kill(pid, SIGTERM) 后,哪个线程处理?

它是进程定向信号,内核从没有阻塞该信号的任意线程中选择一个递送,不保证是主线程或调用 kill 的线程。pthread_kill 才定向到指定线程;同步异常如 SIGSEGV 必须交给触发线程。服务应在创建 worker 前统一 block 管理信号,并用专门线程 sigwaitinfo 或 signalfd 接收。

Q:SIGKILL 和 SIGSTOP 为什么不能被捕获或屏蔽?

它们保留给内核和管理员提供不可撤销的控制权:SIGKILL 必须能终止失控进程,SIGSTOP 必须支持作业控制和调试。若目标进程可安装 handler、屏蔽或忽略它们,系统将无法可靠回收资源或停止任务。

Q:为什么 handler 里不能 printf、malloc 或加 mutex?

信号可能恰好打断同一线程执行这些库函数,而它们内部可能持有锁或处在中间状态。handler 重入后会等待自己先前持有的锁,或破坏分配器/stdio 状态。线程安全不等于 async-signal-safe。handler 应只做 POSIX 保证安全的最小操作,如 write、设置 sig_atomic_t,或更好地避免 handler,使用 sigwait/signalfd。

Q:EINTR 是什么?SA_RESTART 能彻底消除它吗?

阻塞系统调用等待时执行了信号 handler,调用可能返回 -1/EINTR 表示未按原语义完成。SA_RESTART 会自动重启一部分调用,但不是全部;行为依赖 syscall、对象、timeout 和内核语义,应用有时还希望信号取消等待。因此必须按每个 API 的文档处理 EINTR,timeout 重试应基于单调时钟的绝对 deadline,不能每次重置超时。

Q:为什么 SIGPIPE 会让网络服务意外退出?

向所有读端关闭的 pipe/socket 写时,内核让 write 返回 EPIPE,同时默认产生终止进程的 SIGPIPE。若未忽略或屏蔽它,进程可能在检查 EPIPE 前被终止。服务可忽略 SIGPIPE 或使用平台提供的 per-send 抑制选项,但仍必须处理 EPIPE,因为它表示对端断开。

Q:sigwaitinfo 与 signalfd 的共同前提是什么?

相关信号必须被所有可能运行的线程 block,通常在创建线程前由主线程完成,让 worker 继承 mask。否则某个未屏蔽线程可能先异步收到并执行默认动作/handler,signal thread 或 signalfd 根本收不到。它们的价值都是把异步产生的信号转换到正常同步控制流。

Q:Go 的 signal.Notify 为什么要使用缓冲 channel?

Go runtime 在信号到达路径上不能阻塞等待接收者;无缓冲或已满 channel 会导致通知被丢弃。容量至少 1 能可靠表达常见生命周期语义“至少收到一次”,同时符合普通信号本就会合并的事实。它不让普通信号变成精确计数队列;需要精确事件应使用其他 IPC。

Q:容器里为什么常见 SIGTERM 没传到应用?

常见原因是 shell form ENTRYPOINT/CMD 成为 PID 1,SIGTERM 到了 shell 但没有转发给子应用;或应用作为 namespace PID 1 却没显式设计处理;也可能优雅退出超过编排器 grace period,最终被 SIGKILL。使用 exec form 或脚本末尾 exec,使应用直接成为 PID 1;需要 init 职责时用 tini,并让应用自身实现带 deadline 的 shutdown。

Q:137 一定等于 OOMKilled 吗?

不一定。137 常表示进程因 signal 9 退出,但 SIGKILL 可能来自 OOM killer、Kubernetes grace period 到期、管理员或运行时。要查 cgroup memory.events、容器/Pod events、内核日志和终止时间线,不能只凭 exit code 归因。


小结

  • 信号是异步控制流,不是回调线程;handler 在被打断线程上下文中运行,能插进任意普通代码
  • 信号经历产生、挂起、递送、处置;发送成功不代表目标已经执行处理逻辑
  • 传统信号是 pending bit,会合并同类通知;它适合幂等唤醒,不适合可靠计数或消息传输
  • realtime signal 能排队和携带小 payload,但没有消除异步 handler 的限制,也不应替代 IPC
  • signal mask 是线程属性;多线程服务应先 block,再用 sigwaitinfo 或 signalfd 由专用控制流接收
  • SIGKILL/SIGSTOP 不可捕获和屏蔽,保留给系统最后控制权
  • handler 只能调用 async-signal-safe 函数;不要 printfmalloc、加锁或运行业务逻辑
  • EINTR 是信号打断阻塞调用的正常结果;SA_RESTART 有限,timeout 重试要使用绝对 deadline
  • signalfd、self-pipe 和 sigwaitinfo 的共同目标都是把不可控的异步通知变成可控的事件/同步返回
  • Go signal.Notify 把通知交给正常 goroutine;使用容量至少为 1 的 channel,NotifyContext 可组合 cancellation 和第二次强制退出
  • 容器 PID 1 必须正确接收/转发 TERM 并回收子进程;信号只是优雅退出协议的起点,deadline 与崩溃一致性同样必要

下一篇讲 文件系统与 inode 抽象的必然性 —— 路径为什么不是文件、inode 如何把名称和对象拆开、日志文件系统为何必要、ext3→ext4 delayed allocation 的事故教会了什么,以及 fsyncO_DIRECT 的真实边界。