AI 资讯 知识库 操作系统内功操作系统内功 · 第 11 篇|信号与中断:内核如何打断你的程序

操作系统内功 · 第 11 篇|信号与中断:内核如何打断你的程序

你的程序从来不曾真正独占 CPU,内核随时可能打断它。打断其实分两层:硬件中断打的是内核,信号打的是你的进程——网卡收包、时钟到期走中断;Ctrl+C、kill 命令、子进程退出、非法访存走信号。对写后端服务的人来说,这些知识可以浓缩成几条能保命的规则:信号处理函数里只写字节、不调函数;服务进程要显式接住 SIGTERM;容器里的 PID 1 不装 handler,默认终止动作根本不生效;标准信号不排队,别指望它替你计数。本文事实核查截至 2026-10-29,行为性结论一律以 Linux man 手册为准,工程建议为笔者观点。

先分层:中断打内核,信号打进程

硬件中断:上半部只干最急的事

硬件设备用中断通知内核:网卡说「包到了」,时钟说「该调度了」。CPU 每执行完一条指令都会检查中断,从中断向量表查到处理程序入口跳过去执行。内核中断处理的铁律是越快越好:处理程序(上半部)运行期间本中断线是关着的,只做应答硬件、拷走数据这类最要紧的事,解析协议栈、唤醒进程等耗时工作统统推迟给下半部——Linux 里的实现手段是 softirq、tasklet 与 workqueue,《Linux Device Drivers》第 10 章给过经典的上下半部划分。

信号:递到进程门口的条子

信号是软件层面的异步通知,形态比中断温和:内核把一个编号记到目标进程或线程名下,等合适的时机再投递。事件来源五花八门——硬件异常会被内核翻译成信号(非法访存变 SIGSEGV,算术错误变 SIGFPE);终端驱动把 Ctrl+C 变成 SIGINT;别的进程用 kill 调用发来 SIGTERM 或 SIGUSR1;alarm 定时器到期产生 SIGALRM。signal(7) 定义:信号从「产生」到「投递」之间处于挂起(pending)状态。挂起,是理解后面所有坑的关键。

信号怎么走完全程,一张图说清:

flowchart TD
    A["事件发生:网卡收包、时钟到期、Ctrl+C、kill 调用、非法访存"] --> B["内核生成信号,记入挂起队列"]
    B --> C{"信号导向哪里"}
    C -->|"硬件异常,如 SIGSEGV、SIGFPE"| D["线程导向:挂起在引发异常的线程"]
    C -->|"kill、终端、定时器"| E["进程导向:挂起在进程级,可投给任一未屏蔽线程"]
    D --> F{"目标线程是否屏蔽该信号"}
    E --> F
    F -->|"已屏蔽"| G["保持挂起;标准信号不排队,同号只记一个"]
    F -->|"未屏蔽"| H{"查进程级处置表"}
    H -->|"默认动作"| I["终止、终止并转储核心、停止或忽略"]
    H -->|"忽略"| J["直接丢弃"]
    H -->|"自定义 handler"| K["内核返回用户态前插入信号帧,执行 handler"]
    K --> L["handler 返回;被打断的慢系统调用按 SA_RESTART 决定重启还是返回 EINTR"]

图里藏着两个分岔点:信号挂在谁名下(线程导向还是进程导向)、目标屏蔽了吗。不同走向,就是下面这些坑的由来。

信号机制的四条硬规则

规则一:标准信号不排队

编号 1 到 31 是标准信号;31 号以上是 POSIX 实时信号区间。注意 SIGRTMIN 在 glibc 上是 34 或 35——NPTL 线程库自己占了两个内部信号——所以使用时应写 SIGRTMIN 加偏移并运行时检查不越 SIGRTMAX。两者的本质区别在排队:signal(7) 原文说「Standard signals do not queue」,同一个标准信号被屏蔽期间来了 N 次,解除屏蔽后只得到一次投递;实时信号则每个实例都排队。SIGCHLD 是典型受害者:多个子进程同时退出只唤醒一次 handler,所以 handler 里要循环 waitpid,收割到返回零或 ECHILD 为止。

规则二:处置进程共享,掩码线程私有

信号处置表(每个信号是默认、忽略还是 handler)是进程级属性,所有线程共用;信号掩码(屏蔽哪些信号)却归每个线程私有。进程导向的信号会投给「任一没有屏蔽它的线程」,signal(7) 的措辞是内核任选(arbitrary thread);硬件异常则是线程导向的——SIGSEGV 一定砸在引发非法访存的那个线程头上。多线程服务的第一课由此而来:信号会落在你意想不到的线程里。

常用信号的默认动作一张表收好:

信号 典型来源 默认动作 能否捕获、阻塞、忽略
SIGINT 终端 Ctrl+C 终止 可以
SIGTERM kill 命令、容器与系统停机 终止 可以
SIGHUP 终端挂断;守护进程惯例用它重读配置 终止 可以
SIGPIPE 向对端已关闭的管道或连接写入 终止 可以
SIGCHLD 子进程终止 忽略 可以
SIGUSR1、SIGUSR2 用户自定义 终止 可以
SIGALRM alarm 定时器到期 终止 可以
SIGSEGV 非法内存访问 终止并转储核心 可捕获,但崩溃后状态不可信
SIGKILL、SIGSTOP 管理员或内核 终止、停止 一律不可捕获、不可阻塞、不可忽略

规则三:安装 handler 用 sigaction,别用 signal

signal(2) 手册开篇就是警告:「Avoid its use: use sigaction(2) instead」,它唯一可移植的用法是设置 SIG_DFL 或 SIG_IGN。glibc 的 signal() 其实是包了 BSD 语义的 sigaction(隐含 SA_RESTART),内核原生 signal 系统调用却是 System V 语义,POSIX 干脆规定多线程进程里 signal() 的行为未定义。sigaction 把选择全部摆上台面:SA_RESTART 控制系统调用被打断后是否自动重启;SA_SIGINFO 让 handler 多拿一个 siginfo_t,能读到发送方 pid 与故障地址;SA_ONSTACK 让 handler 跑到备选栈上,避免栈本身出事时无处落脚。

规则四:EINTR 不会因为 SA_RESTART 消失

SA_RESTART 只让被 handler 打断的「慢」系统调用自动重启,signal(7) 另列了一份「永远不重启」的清单:select、poll、epoll_wait 全家,pause 与 sigsuspend,带超时的套接字读写,nanosleep 等 sleep 接口,一律返回 EINTR。最戏剧性的是 sleep(3):它返回「成功」,值是没睡够的秒数。所以生产代码要两条腿走路——装 handler 时上 SA_RESTART,同时所有可阻塞调用写 EINTR 重试循环。Go 1.14 给整个生态上过一课:异步抢占落地后程序收到的信号变多,官方发布说明专门警告「慢系统调用会更多以 EINTR 失败,请循环重试」,还顺手链接了 man 7 signal。

工程里的经典坑

坑一:信号砸进任意线程,handler 里调不可重入函数就死锁

signal-safety(7) 维护着一份「异步信号安全」函数白名单,短得惊人。printf 不在名单上:stdio 靠静态缓冲区与计数器工作,主流程 printf 打到一半被信号打断,handler 里再 printf,操作的就是不一致的数据,结果是未定义的。malloc 与 free 同理——堆是带锁的全局结构,handler 里一申请内存就可能撞上「锁被被自己打断的那段代码持有」的死锁。中文社区记录过大量同形态事故:日志线程在 localtime_r 里持锁,信号恰好投给它,handler 里又打日志,进程当场自锁。signal-safety(7) 给的正解是 handler 只做两件事——置一个 volatile sig_atomic_t 标志,或往管道写一个字节,其余活回主循环干;另外进门先存 errno、出门还原,否则 handler 可能改写主流程刚拿到的错误码。

坑二:SIGPIPE,忘了它进程会被写崩

向一个对端已关闭的连接写数据,内核默认动作是终止进程(signal(7) 表里 SIGPIPE 是 Term)。长连接服务最怕这个:客户端断开、服务端回包,进程无声无息退出。标准姿势有两条:用 sigaction 把 SIGPIPE 置为 SIG_IGN,进程级一次生效,写操作改为返回 EPIPE,把错误交回代码;或按次加 MSG_NOSIGNAL 标志,send(2) 原文说「The EPIPE error is still returned」——信号没了,错误码还在。真正的坑有两种:只拦了信号却没处理 EPIPE,连接泄漏到超时;或库代码擅自全局改 SIGPIPE 处置,悄悄改变宿主程序的行为。

坑三:容器里的 PID 1,默认动作不生效

内核对 PID 1(容器里的 1 号进程)有特殊待遇:它没有显式注册 handler 的终止信号,不会被默认动作杀死——这是保护 init 不被误伤的老规矩。后果是一条完整的翻车链:应用直接当 PID 1,docker stop 发来 SIGTERM,内核看它没接 handler,什么都不做;docker 等满 10 秒,补一个 SIGKILL。优雅退出从头到尾没发生过。用 shell 脚本包一层也一样——CMD ["./run.sh"] 时 shell 是 PID 1,它不转发信号给子进程。PID 1 还得收割孤儿僵尸,否则进程表慢慢堆满。成熟解法是让专业 init 转发信号并收尸:tini,或 docker run --init。Deni Bertović 那篇经典分析把这个链条讲得最透。

坑四:连运行时都会踩的信号号冲突

Go 1.14 的异步抢占以 SIGURG 实现(设计文档 24543),与用户依赖的 C 库用了同一号信号的程序当场出事,go-gl 项目就收过这类 issue。教训值得记住:信号编号是稀缺的公共资源,谁都能发;你的 handler 要假设它可能在任何两条指令之间到达,别和它赌时序。

实战模式

self-pipe 与 signalfd:把信号变成一个字节或一个 fd

自建管道是 select(2) 手册 NOTES 里亲自背书的技巧:主程序建一根非阻塞管道,把读端放进 select 的读集合;handler 里只干一件事——往写端写一个字节。信号到达由此变成普通 IO 事件,主循环统一处理。它解决的老竞态是:先查标志再进 select,信号恰好在两步之间到达,select 从此长眠;带 sigmask 参数的 pselect 用原子切换掩码堵住这个窗口,self-pipe 是不依赖它的可移植做法。更进一步,signalfd(2) 把信号直接变成可读 fd,read 拿到 siginfo 风格的结构,可以进 epoll。但手册要求先 sigprocmask 屏蔽待接收信号,否则信号会在 fd 可读之前被默认处置处理掉;而且 SIGKILL、SIGSTOP 与 SIGSEGV 这类同步信号收不了。

timerfd:定时器也事件化

setitimer 加 SIGALRM 的老路子把定时事件也变成了信号,绕不开异步安全约束。timerfd_create(2) 把到期通知变成 fd 可读,select、poll、epoll 全部可监听,read 返回 8 字节的累计到期次数;配 TFD_TIMER_CANCEL_ON_SET 还能感知系统时钟跳变(NTP 校时、手动改时间),此时 read 以 ECANCELED 失败。至此定时器与网络事件共用一个 epoll 循环,正是上一篇 IO 多路复用图景的补全。

systemd 的优雅停机链路

systemctl stop 之后的链条值得背下来:systemd 按 KillSignal(默认 SIGTERM)通知主进程;配了 ExecStop 就执行它,手册要求写成同步操作;然后等 TimeoutStopSec,默认继承管理器的 DefaultTimeoutStopSec,即 90 秒;超时则按 KillMode(默认 control-group,殃及整个 cgroup)补 SIGKILL。应用方的义务是接住 SIGTERM、停止接收新请求、排空在途请求、尽快退出。把 docker 的 10 秒和 systemd 的 90 秒放一起看就明白:兜底时钟永远存在,应用要按最紧的那条设计——超时是强制的墙,不是温和的建议。

常见误区

  • 「handler 里想干什么都行」:只有异步信号安全函数可用,printf、malloc、syslog 都不在白名单,打日志请回主循环。
  • 「上了 SA_RESTART 就不用管 EINTR」:epoll_wait、nanosleep 等永远返回 EINTR,重试循环必须写。
  • 「标准信号会排队」:屏蔽期间同号信号合并成一个挂起位;要求事件不丢,用实时信号或事件 fd。
  • 「信号是进程级的,哪个线程处理都一样」:处置表确实进程共享,但掩码线程私有、投递线程内核任选;正确姿势是统一屏蔽加专职信号线程 sigwait,或 self-pipe。
  • 「容器里 SIGTERM 总会终止进程」:PID 1 未注册 handler 时默认动作不生效,docker stop 白等 10 秒。
  • 「除了 SIGKILL 都能拦」:SIGSTOP 同样不可捕获、不可阻塞;signal(2) 手册还明文警告,忽略硬件产生的 SIGFPE、SIGILL、SIGSEGV 之后进程行为未定义。

参考资料

本文为技术科普,行为性结论请以对应 man 手册与你所用内核的实际行为为准。

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?