signal 是 Unix 1970 年代的设计遗产。它同时做了三件事——异步通知、进程控制、错误递送——这三者本应是不同的抽象,但被塞进了同一套 API。结果就是每个资深工程师都被 signal 坑过至少一次。
本文不复述 man page,只讲三件事:内核怎么递送、用户态怎么接、如何在现代代码里尽量绕开 signal 的地雷。
一、先看图
flowchart LR
subgraph Producer[发送端]
K[kill/tgkill/<br/>pidfd_send_signal] --> Q
KI[内核产生<br/>SIGSEGV/CHLD/...] --> Q
end
Q[pending set<br/>per-task + per-tgroup]
Q --> D{blocked?}
D -->|yes| W[等待解除屏蔽]
D -->|no| R[返回用户态前<br/>check signal]
R --> H{handler?}
H -->|SIG_DFL| DA[默认动作:<br/>term / core / stop / ignore]
H -->|SIG_IGN| IG[丢弃]
H -->|自定义| UH[切 sigframe 到用户栈<br/>调用 handler]
UH --> RT[sigreturn 恢复]
classDef kernel fill:#388bfd22,stroke:#388bfd,color:#adbac7;
classDef user fill:#3fb95022,stroke:#3fb950,color:#adbac7;
classDef warn fill:#f0883e22,stroke:#f0883e,color:#adbac7;
class Q,D,R,RT kernel
class UH user
class DA warn
关键点:signal 只在从内核返回用户态前被检查。这意味着一个 busy loop 不 enter kernel 就永远不会被软中断(但 preempt 仍会把它踢下来,调度 tick 算 kernel entry)。
二、信号分类
2.1 标准信号 1-31
历史包袱:SIGHUP/SIGINT/SIGKILL/SIGTERM/SIGCHLD/… 这些是 SysV/BSD 流传下来的固定号。关键属性:
- 不排队:同号 pending 多次 = pending 一次
- 有预定义默认动作(term / core / stop / ignore)
- SIGKILL 和 SIGSTOP 不可屏蔽、不可捕获
2.2 实时信号 32-64(SIGRTMIN..SIGRTMAX)
POSIX RT 扩展:
- 会排队(每个进程有
RLIMIT_SIGPENDING限制,默认几千) - 可带
sigval_tpayload - 默认动作是 term
适合要数据、要计数的异步通知。但注意 glibc 会偷用
SIGRTMIN..SIGRTMIN+2(pthread
cancel、setxid)。实际可用从 SIGRTMIN+3
开始。systemd 用一大堆 SIGRTMIN+N 做运行时控制。
2.3 同步 vs 异步
- 同步:由当前指令触发——SIGSEGV(坏地址)、SIGBUS(对齐/mapped
区读写无效)、SIGFPE、SIGILL、SIGTRAP。它们带
si_addr等精确信息,只能递给触发的那个线程。 - 异步:kill(2) 产生。可送给进程或特定线程。
三、发送 API
3.1 kill(2) 和 tgkill(2)
kill(pid, sig):送给进程(thread
group)。内核选一个未屏蔽此信号的线程递送。坑:你不能指定哪个线程接。
tgkill(tgid, tid, sig):送给特定线程。glibc
的 pthread_kill 底层就是 tgkill。
3.2 sigqueue(3)
带 payload 的发送,走 RT signal 机制。
3.3 pidfd_send_signal(2)
前文说的 pidfd 加持:
int pidfd = pidfd_open(pid, 0);
pidfd_send_signal(pidfd, SIGTERM, NULL, 0);优势:pid 不会误杀(见 B-12 第 7.2 节)。5.1+ 可用。
四、接收 handler 的雷区
4.1 signal(2) vs sigaction(2)
永远不要用
signal(2)——语义在 BSD / SysV
不一致,且有些平台重置 handler 为 SIG_DFL
后再次递送会丢。sigaction(2) 是 POSIX 正规
API。
struct sigaction sa = {0};
sa.sa_sigaction = my_handler;
sa.sa_flags = SA_SIGINFO | SA_RESTART;
sigemptyset(&sa.sa_mask);
sigaction(SIGCHLD, &sa, NULL);关键 flags:
SA_SIGINFO:用 3-arg handler,拿到 siginfo_t(有 si_pid、si_code、si_addr)SA_RESTART:系统调用被中断时自动 restart(多数时候你要的行为)SA_NOCLDWAIT:SIGCHLD时直接 reap,不留僵尸SA_ONSTACK:handler 跑在 sigaltstack 上(栈溢出时保命)
4.2 async-signal-safe
signal handler 里只能调 POSIX 标为 AS-safe 的函数。这是硬约束,不是建议。违反后果:
- glibc 内部锁被中断 handler 再 acquire → 死锁
- malloc 中间态被打断 → 堆损坏
AS-safe 子集很小:write、read、_exit、sigaction、sem_post、cfsetospeed、几何上一把。printf 不 safe,malloc 不 safe,任何 FILE* 操作都不 safe。
所以 handler 的正确姿势:
- 只写一个
volatile sig_atomic_t flag = 1;,主循环轮询 - 或者
write(pipe_wr, &byte, 1),主循环 epoll 读 pipe - 或者
sem_post唤醒工作线程
复杂逻辑禁止放 handler 里。
4.3 errno 陷阱
signal handler 执行时会被各种 syscall 夹住,syscall 可能改 errno。handler 里如果调了 write 等会改 errno 的函数,必须先保存 errno、退出前恢复:
void h(int s) {
int saved = errno;
write(pipefd, "X", 1);
errno = saved;
}4.4 多线程语义
sigprocmask:只改当前线程pthread_sigmask:显式改当前线程- 收信时:若一个线程屏蔽了,kernel 尝试下一个;都屏蔽则 pending 在进程级
- 最佳实践:主线程 block 所有关心的 signal,起专用线程 sigwaitinfo 循环消费
五、现代替代:signalfd + pidfd
signalfd(2) 把 signal 变成 fd:
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGCHLD);
pthread_sigmask(SIG_BLOCK, &mask, NULL);
int sfd = signalfd(-1, &mask, SFD_CLOEXEC);
// 加入 epoll好处:
- 不走 handler,不受 AS-safe 约束
- 可与 epoll/io_uring 合流
- 线程安全——谁 read 谁处理
坑:signalfd 需要先 block 对应 signal,否则 kernel 优先走 handler 路径。
pidfd + epoll 已在 B-12 讲;在只关心”子进程退出”时,pidfd 比 SIGCHLD 干净得多。
六、SIGCHLD 的三种姿势
- SIG_DFL:父忽略,子变僵尸
- SIG_IGN 或 SA_NOCLDWAIT:子退出即 reap,失去 exit code
- 自定义 handler + waitpid 循环:传统姿势
void sigchld(int s) {
int saved = errno;
while (waitpid(-1, NULL, WNOHANG) > 0) {}
errno = saved;
}要循环 wait——SIGCHLD 不排队,短时间多子退出可能只收到一次。
七、SIGSEGV:故障处理的艺术
进程访问非法地址 → kernel 产生 SIGSEGV 送自己。默认是 core dump。
但高级用法:
- userfaultfd:JVM/QEMU 用于 live migration、lazy load(配合 B-34 缺页)
- 自定义 SIGSEGV handler:某些 GC 用(guard page 触发)
- mprotect 探测:先 mmap 巨大匿名区,SIGSEGV handler 动态 mprotect
handler 里要用 sigaltstack,否则栈溢出触发 SEGV 时没地方跑 handler。
八、SIGPIPE 与 EPIPE
写入已 close 的 pipe/socket → 内核送 SIGPIPE(默认 term)。网络服务里这是常见踩雷:
signal(SIGPIPE, SIG_IGN); // 或 sigaction
// 或每次 send 用 MSG_NOSIGNAL否则客户端一断,服务端进程就死。
九、容器里的 signal 流转
Docker stop / kubectl delete pod 的流程:
- 发 SIGTERM 给容器 PID 1
- 等 grace period(默认 10/30s)
- 发 SIGKILL
PID 1 不装 SIGTERM handler 时,kernel 忽略 SIGTERM(PID 1 对信号有特殊待遇——无 handler 的默认都忽略,防止宕内核)。
解决:
docker run --init(塞个 tini 做 PID 1,它转发 signal 给应用)- 应用自己装 handler
- Dockerfile 用
exec formCMD(CMD ["./app"])而不是 shell form(CMD ./app,会让 sh 当 PID 1)
十、常见 signal bug 模板
bug A:log rotation 后 nginx 没切日志 原因:reload 发的是 SIGUSR1 给 master;master 处理了但 worker 没 解决:按软件文档 reload 模式
bug B:gdb attach 后进程被卡
原因:ptrace 会 stop;detach 后若 deliver
了 SIGSTOP 就需要 SIGCONT
解决:kill -CONT <pid>
bug C:pthread_cancel 不生效 原因:cancel 通过 SIGRTMIN 实现;被屏蔽或 handler 被覆盖 解决:不要手动玩 SIGRTMIN..+2
bug D:strace 下进程行为不一样 原因:strace 改了 signal delivery timing;某些竞争被隐藏 解决:用 perf trace 或 eBPF 替代,干扰更小
十一、小结
- signal 是异步+同步+进程控制的三合一,API 拧巴,但 Unix 深度耦合
- 尽量用 sigaction;禁止在 handler 里做复杂事
- 多线程:一个线程统一 sigwait,其他全 block
- 新代码优先 signalfd / pidfd,让 signal 变 fd
- 记住 SA_RESTART、SIGPIPE、SIGCHLD 排队的坑
下一篇 B-14 讲管道、FIFO、socketpair——Unix 风格进程间通信的老牌三剑客。
参考文献
- Kerrisk, M. The Linux Programming Interface, Ch. 20-22
- Linux source:
kernel/signal.c,arch/x86/kernel/signal.c - POSIX.1-2017 §2.4 “Signal concepts”
signal-safety(7)、signal(7)man pages- Corbet, J. “signalfd() and beyond.” LWN.net 2007
- Drysdale, D. “The perils of signal() in libraries.” 2013
工具
kill -l—— 列出所有 signalstrace -e trace=signal—— 信号追踪bpftrace -e 'tracepoint:signal:signal_deliver { ... }'—— 事件级perf trace -e 'signal:*'prctl PR_GET_PDEATHSIG—— 父死时子收到的信号
上一篇:进程生命周期 下一篇:管道、FIFO、socketpair
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【操作系统百科】fd 化抽象
Linux 把信号、事件、定时器、进程都变成 fd——signalfd/eventfd/timerfd/pidfd。本文讲每种 fd 的用途、与 epoll 组合、KVM 的 eventfd、systemd 的 pidfd、以及 fd 化哲学。
【操作系统百科】fd 表与 struct file
fd 是用户态访问文件的句柄——但 fd 的共享语义在 fork/exec/dup/CLOEXEC 下极其微妙。本文讲 files_struct、fdtable、close_range、pidfd_getfd、cloexec 默认化趋势。
【操作系统百科】进程生命周期:clone → exec → exit → reap
一个进程从诞生到尸体被回收,在内核里走过六个阶段:clone → run → exec(可选)→ exit → zombie → release。本文按阶段讲 do_fork、bprm_execve、do_exit、release_task,以及 waitpid/pidfd/subreaper 的收尸规则、孤儿与僵尸的语义、systemd PID 1 的特殊性。
【操作系统百科】内存回收
Linux 内存回收是 VM 最复杂的子系统之一。本文讲 active/inactive LRU、kswapd 与 direct reclaim、watermark 三线、swappiness 的真实含义、MGLRU 改造、memcg 回收与 PSI。