Linux-29 信号:背着 40 年债的机制
前一篇的 futex 把「等某个内存值变化」变成了可控的同步协议。这一篇看 Linux 里更古老、也更不规整的一套通知机制:信号(signal)。
信号最初诞生于 Unix 早期,目标是让终端、内核和另一个进程能对正在运行的程序说一句极短的话:停止、继续、退出、子进程结束、定时器到了。它的代价是:这句话可以在任意指令之间插进来,而且早年的信号甚至不排队。
今天几乎每个服务都要处理 SIGTERM,每个 shell 都依赖 SIGINT 和 SIGCHLD,每个网络程序都可能遇到 EINTR。但「捕获一个信号」远不等于调用一次普通回调。
先看五个问题:
- 信号为什么不是函数调用?它在程序执行的哪个时刻真正运行?
- 普通信号为什么会丢,而实时信号为什么不会?
- 一个多线程进程收到
SIGTERM后,究竟由哪个线程执行处理函数? read被信号打断为什么会返回EINTR?SA_RESTART为什么又不能彻底解决问题?- 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_t 的 si_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 通常从 SIGRTMIN 到 SIGRTMAX(Linux 常见 34~64,实际范围受 libc/线程库影响)。它们有两个关键不同:
- 每次发送会排队,不与同号信号合并
- 可携带一个整数或指针值(
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);
不能阻塞、捕获或忽略:SIGKILL 和 SIGSTOP。这是内核留给管理员和作业控制的最后控制权;若进程能屏蔽 SIGKILL,失控进程就无法收拾。
3.2 mask 是线程属性,不是进程全局属性
单线程时代「进程屏蔽信号」的说法还勉强可用。在线程模型中,mask 属于线程:
同一进程:
T1 mask:block SIGTERM
T2 mask:allow SIGTERM
进程定向 SIGTERM 到达
-> 内核可以选择 T2 递送
新线程通常继承创建它的线程的 mask。因此常见可靠模式是:主线程在创建 worker 前先 block 相关信号,让所有线程都继承 block;再由一个专门线程用 sigwaitinfo() 同步消费。这会在第 6 章展开。
3.3 sigprocmask 与 pthread_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 同步异常不能随便挪给别的线程
SIGSEGV、SIGFPE、SIGILL 常来自一条具体指令的同步异常。它们必须在触发线程上处理,因为恢复/终止都依赖该线程的寄存器上下文。
不要把 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 都不安全
信号可能刚好打断线程在 malloc、printf、fprintf、pthread_mutex_lock 或 Go runtime 内部执行时。handler 再调用同一函数,可能拿到被自己打断前已持有的内部锁:
主程序正在 printf
-> libc stdio lock 已持有
-> SIGTERM handler 被异步调用
-> handler 里再次 printf
-> 等自己先前持有的锁 -> 死锁
malloc/free 同理。信号安全不是函数“线程安全”就够了;线程安全函数可以靠锁保护,handler 的问题是重入到被中断代码的同一线程。
POSIX 定义了一小组 async-signal-safe 函数。常见安全成员包括:
_exitwritekillsigactionsigprocmaskwaitpid(按规范条件)
完整名单以 man 7 signal-safety 为准。printf、malloc、free、syslog、大多数 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”。原因:
- 不是所有 syscall 都可重启;例如带 timeout 的等待、某些 socket/多路复用调用有特殊规则
- 同一调用的可重启性可能依赖文件类型、是否已传输部分数据、内核版本
- 即使可重启,应用有时希望信号尽快取消等待,而不是继续卡住
- 库和语言运行时可能自行转换、屏蔽或重试信号
稳妥原则是:任何可能阻塞的 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 写数据时,如果所有读端都已关闭,内核通常同时:
- 让
write()返回EPIPE - 产生默认动作为终止的
SIGPIPE
若不处理,进程可能在来得及检查 EPIPE 前被杀掉。网络服务常见做法:
signal(SIGPIPE, SIG_IGN); // 示意;严肃代码用 sigaction
或在 socket 上使用 MSG_NOSIGNAL(Linux)/SO_NOSIGPIPE(部分 BSD)避免生成 SIGPIPE。忽略 SIGPIPE 后仍必须处理 write 的 EPIPE,它代表对端连接已断。
Go runtime 默认会对 SIGPIPE 做符合网络服务预期的处理;但 cgo 或调用外部库时仍应理解底层语义,而不是依赖偶然行为。
7. 用同步接口驯服异步信号
7.1 sigwaitinfo:推荐的多线程模型
对服务管理类信号(SIGTERM、SIGINT、SIGHUP、SIGUSR1),最干净的 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 限制。信号仍是内核异步产生的,但应用把它接收为同步阻塞调用的返回值。
不要对 SIGSEGV、SIGFPE 这类同步异常用 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 使用 SIGSEGV、SIGBUS 等实现内存访问故障、栈增长和 panic/crash 语义的一部分。应用不应随意用 signal.Notify 处理这些信号来“捕获所有崩溃”;这可能破坏 runtime 的预期行为。
处理服务生命周期优先选择:os.Interrupt、SIGTERM、SIGHUP、必要时 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 至少要:
- 收到 SIGTERM 后停止接收新工作或返回 readiness false
- 给在途请求、消息 ack、事务提交一个明确 deadline
- 在 grace period 内退出
- 能承受 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 -9 是 SIGKILL,不能被捕获、阻塞、清理资源或执行 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 函数;不要
printf、malloc、加锁或运行业务逻辑 EINTR是信号打断阻塞调用的正常结果;SA_RESTART有限,timeout 重试要使用绝对 deadlinesignalfd、self-pipe 和sigwaitinfo的共同目标都是把不可控的异步通知变成可控的事件/同步返回- Go
signal.Notify把通知交给正常 goroutine;使用容量至少为 1 的 channel,NotifyContext可组合 cancellation 和第二次强制退出 - 容器 PID 1 必须正确接收/转发 TERM 并回收子进程;信号只是优雅退出协议的起点,deadline 与崩溃一致性同样必要
下一篇讲 文件系统与 inode 抽象的必然性 —— 路径为什么不是文件、inode 如何把名称和对象拆开、日志文件系统为何必要、ext3→ext4 delayed allocation 的事故教会了什么,以及 fsync 与 O_DIRECT 的真实边界。
xingliuhua