
进程收到 SIGTERM 后要停止接流量,收到 SIGHUP 后要重载配置,看起来只需注册两个回调。然而信号处理函数会在不可预测的指令之间打断程序;若在里面加锁、分配内存或写日志,偶发死锁和状态损坏便可能随之而来。稳妥的思路是:处理函数只负责通知,把真正的工作交还主循环。
信号处理函数为什么特殊
普通函数由程序主动调用,信号处理函数却可能打断任意线程。被打断的代码也许正持有 malloc 或日志库内部的锁,此时处理函数再次调用同一套设施,就会等待一把只有自己返回后才可能释放的锁。
POSIX 只保证一小组函数是异步信号安全的,例如 write、close 和 _exit。printf、malloc、大多数日志接口以及互斥锁都不在其中。把处理函数写得“很短”并不等于安全,关键是它调用了什么、读写的数据能否在异步打断下保持正确。
还有两个容易忽略的事实:普通信号通常不会像消息队列一样逐个排队,同一种信号快速到达多次,程序可能只观察到一次;阻塞中的系统调用也可能以 EINTR 返回,是否自动重启取决于调用和信号安装方式。因此,信号适合表达“状态需要检查”,不适合承载精确计数或业务数据。
三种方案怎么选
| 方案 | 处理函数做什么 | 适用场景 | 注意点 |
|---|---|---|---|
sig_atomic_t 标志 |
设置标志位 | 主循环本来就会周期醒来 | 休眠可能延迟响应 |
| Self-Pipe | 向管道写一个字节 | select、poll、epoll 主循环 |
管道满时只保留“有事件”语义 |
signalfd |
无传统处理函数 | Linux 专用事件循环 | 所有相关线程都要正确屏蔽信号 |
单线程小程序可以使用 volatile sig_atomic_t 标志,并在正常控制流里检查。事件循环若可能无限阻塞,标志位本身无法唤醒它,Self-Pipe 更合适。已经绑定 Linux、希望读取信号编号和发送者信息时,可选择 signalfd。

用 Self-Pipe 唤醒主循环
Self-Pipe 的做法是创建一条非阻塞管道,把读端加入 poll;处理函数只向写端写入一个字节。即使写入因管道已满而得到 EAGAIN,也说明读端已经可读,唤醒目的仍然达成。
1 | static int signal_fd[2]; |
生产代码还应检查初始化调用的错误,并在安装处理函数前完成管道创建。读取时一次性排空所有字节,但不要把每个字节都当成可靠计数:管道可能合并“需要处理”的通知。若必须区分退出与重载,可在排空后检查独立标志位;标志表达状态,管道负责唤醒。
用 signalfd 统一事件来源
signalfd 能把信号变成文件描述符上的结构化记录,直接纳入 epoll。正确顺序是先用 pthread_sigmask 屏蔽目标信号,再创建 signalfd,随后从它读取 signalfd_siginfo。多线程程序尤其要在创建工作线程之前设置屏蔽掩码,让新线程继承;否则信号可能被投递到未屏蔽的线程,绕过 signalfd,甚至触发默认动作。
1 | sigset_t mask; |
signalfd 简化了 Linux 服务的控制流,但它不是跨平台接口。需要兼容其他 Unix 系统时,Self-Pipe 往往更易移植。
把退出做成状态机
收到 SIGTERM 不应立即在处理函数里关闭所有资源。主循环可以按顺序进入“停止接收新任务、等待进行中任务、到期强制结束、刷新必要状态、退出”的阶段。再次收到退出信号时,可缩短宽限期或直接进入强制阶段。SIGHUP 重载也应先解析并校验新配置,成功后再原子替换,避免半更新状态。
测试时不要只按一次 Ctrl+C。应覆盖主循环空闲和繁忙时的信号、连续多个同类信号、退出期间再次退出、系统调用返回 EINTR,以及多线程下的投递行为。信号处理最可靠的边界不是“回调足够聪明”,而是让异步上下文只发出最小通知,让所有复杂操作回到可测试、可排序的正常控制流。