1. 信号处理为什么是系统编程里最容易翻车的一块
写了十多年 Linux 下的后台服务,如果让我排一个"看起来简单、写起来处处是坑"的榜单,Linux 系统编程里的信号处理绝对排得进前三。它不像内存管理那样有成熟的一套工具链帮你兜底,也不像并发那样有大量教科书式的模式可以照抄。信号这东西的特点是:平时不出现,一出现就要命。你写的守护进程跑了三个月没事,某天运维发来一条消息说进程莫名退出了,翻日志什么都没有,最后发现是某个库函数里一个没处理的 SIGPIPE 把进程带走了。
这就是信号处理的真实处境。它解决的是"异步事件如何通知进程"这个最底层的问题,覆盖的场景非常广:进程收到终止请求要优雅退出、子进程退出要回收避免僵尸、定时任务要周期性触发、写管道对端关闭要避免被误杀、内存越界要留下现场而不是直接蒸发。任何一个正经的后台服务,只要涉及生命周期管理,就绕不开这一套。
这篇文章面向两类人。一类是刚接触 Linux 系统编程、能写 fork 和 pipe 但对 sigaction 那一堆字段发怵的同学;另一类是已经写过服务、被信号问题坑过一两次,想把这块知识系统补齐的开发者。我打算从机制原理讲到 API 细节,再给一套我自己在项目里反复用、改了七八个版本才稳定下来的信号处理框架,最后把这些年遇到的典型故障和排查思路摆出来。代码都是可以直接编译运行的,参数选择我会解释清楚为什么这么定,不会只丢一句"照做就行"。
需要先说明的是,下面所有的场景和案例都是我在实际工程中遇到的典型形态的抽象,涉及的具体项目信息做了脱敏处理,你把它套到自己手上的代码里对照着看就行。
2. 信号机制的核心原理拆解
2.1 信号从产生到递送的完整链路
很多人写信号处理卡住,根本原因不是 API 记不住,而是脑子里没有一张"信号是怎么走到我的处理函数里"的图。这张图不清楚,你就永远搞不明白为什么处理函数里只能调那么几个函数,也搞不明白为什么信号会"丢"。
信号的完整生命周期分三步:产生、挂起、递送。产生的方式有几种——内核检测到异常(除零、非法内存访问)、用户态主动发起(kill、raise、pthread_kill)、终端驱动产生(Ctrl+C 对应 SIGINT)、硬件或软件定时器到期、以及子进程状态变化。产生之后,内核并不是立刻就叫醒目标进程去执行处理逻辑,而是先在这个进程的待处理信号集(pending set)里打一个标记。
这里有个关键设计:标准信号(编号 1 到 31)在 pending 集里是位图表示,一位对应一个信号。这意味着同一个标准信号在被处理之前,重复产生只会置位一次,后来的全部被合并丢弃。这就是"信号会丢"的根源,也是我后面要讲的很多坑的起点。实时信号(SIGRTMIN 到 SIGRTMAX)用的是队列,同一个信号可以排队多个,还带一个附加的整数值。
递送的时机取决于两件事:这个信号是否被阻塞(blocked),以及进程当前是否在运行。如果没被阻塞,内核会在进程从内核态返回用户态的那个瞬间插入处理逻辑——这就是为什么信号处理函数是"异步"的,它可能插在你任何一条指令之间执行。如果被阻塞了,信号就一直挂在 pending 集里,直到你解除阻塞(sigprocmask)或者用 sigwait/sigwaitinfo 主动去取。
关键认知:信号处理函数不是"另一个线程",它是在当前线程的栈上、以内核插入的方式执行的。所以它对栈的使用、对全局变量的访问,和主流程是共享的,这就引出了可重入问题。
理解这条链路之后再回头看那些奇怪的规则就顺了:为什么处理函数里不能调 printf?因为 printf 内部有锁和静态缓冲区,如果主流程正好在 printf 里被信号打断,处理函数再调 printf 就可能死锁或者把缓冲区搞乱。为什么处理函数里不能调 malloc?同理,堆的内部链表可能正处于半更新状态。
2.2 标准信号与实时信号的差异对照
这个区别在实际选型时很关键,但很多资料一笔带过。我整理成表格方便对照:
| 对比维度 | 标准信号(1-31) | 实时信号(SIGRTMIN-SIGRTMAX) |
|---|---|---|
| 排队能力 | 不排队,同类信号合并 | 排队,按产生顺序递送 |
| 携带数据 | 只能带信号编号 | 可通过 sigqueue 附带一个整型或指针 |
| 递送顺序 | 未定义(通常按编号小到大) | 严格按产生顺序 |
| 默认动作 | 各有不同 | 默认都是终止进程 |
| 适用场景 | 进程控制、异常通知 | 需要可靠投递的业务事件 |
我在项目里的一条经验是:进程控制类(终止、挂起、子进程退出)用标准信号就够了,业务事件千万别用标准信号。曾经有个模块用 SIGUSR1 来通知"有新任务到达",压测的时候任务密集到达,SIGUSR1 被合并,丢了将近三成的任务,排查了两天才定位到。后来改成实时信号加队列,问题消失;再后来干脆改成 signalfd 配合 epoll,彻底把信号变成了文件事件。
2.3 默认动作速查与不可捕获的例外
信号的处理方式有三种:默认动作、忽略、捕获。默认动作又分终止、终止并产生 core dump、忽略、暂停、继续几类。日常打交道最多的那几个必须记牢:
| 信号 | 编号 | 默认动作 | 典型用途与注意点 |
|---|---|---|---|
| SIGHUP | 1 | 终止 | 终端断开;常被守护进程重载配置 |
| SIGINT | 2 | 终止 | Ctrl+C;交互式程序退出 |
| SIGQUIT | 3 | 终止+core | Ctrl+\;可用作调试转储 |
| SIGKILL | 9 | 终止 | 不可捕获、不可忽略、不可阻塞 |
| SIGSEGV | 11 | 终止+core | 非法内存访问;捕获后必须终止,不要试图恢复 |
| SIGPIPE | 13 | 终止 | 写已关闭的管道/套接字;必须处理 |
| SIGALRM | 14 | 终止 | alarm 定时到期 |
| SIGTERM | 15 | 终止 | 优雅退出请求的标准信号 |
| SIGCHLD | 17 | 忽略 | 子进程状态变化;默认忽略仍会产生僵尸 |
| SIGSTOP | 19 | 暂停 | 不可捕获、不可忽略、不可阻塞 |
有两类信号是绝对不能捕获、忽略或阻塞的:SIGKILL 和 SIGSTOP。内核层面就拦掉了,sigaction 对它们返回 EINVAL。这一点在设计"优雅退出"机制时很重要——你的程序必须对 SIGKILL 保持敬畏,也就是要做好随时被强制终止的准备,关键状态要持久化落盘,不能指望在处理函数里收尾。
还有一类容易忽略的细节:SIGCHLD 的默认动作虽然是"忽略",但这个忽略和显式设置 SIG_IGN 不是一回事。默认忽略会产生僵尸进程,而显式设置 SIG_IGN 后内核会自动回收子进程,不留僵尸。这个区别我在第 5 节会展开讲,它是我踩过最久的坑之一。
2.4 为什么说信号的语义是"中断"而不是"消息"
这个认知偏差害过不少人。很多同学把信号当成一种轻量的进程间通信方式,觉得可以拿它传业务数据,于是写出"收到 SIGUSR1 就认为有新数据"这种逻辑。这种写法在低负载下能跑,一到生产环境就出事。
原因在于信号没有上下文。它本质上是一个中断通知,告诉你有事件发生了,但不保证事件的完整性和顺序。真正的数据应该走管道、消息队列或共享内存,信号只作为"去看看"的提示。经典的 self-pipe 技巧就是基于这个思路:信号处理函数里只做一件事——往一个管道里写一个字节,主循环在管道上阻塞等待,读到字节再去干真正的活。这样就把异步的信号上下文转换成了同步的、可控的普通代码路径。
我个人的选型原则是:能用 signalfd 就不自己写 handler,非要写 handler 就只做置标志位或者写管道这两件事。处理函数里越简单,出问题的概率越低。这个原则看起来保守,但在我经手的项目里,遵循它的模块至今没有出过信号相关的线上故障。
3. 关键 API 与参数选择的实操细节
3.1 为什么我总是用 sigaction 而不是 signal
新手教程里几乎都用 signal(),因为它签名简单:signal(SIGINT, handler)一行就完事。但我在任何正式项目里都不用这个函数,理由有三个。
第一,语义在不同实现间不一致。老版本的 System V 语义下,信号处理函数执行一次之后会自动复位成默认动作,这意味着你必须在处理函数里重新注册一次,否则第二次信号就按默认动作走了。BSD 语义则不自动复位。虽然现在 Linux 的 glibc 包装了 BSD 语义,但换到别的平台可能就是另一套行为。
第二,signal 无法控制阻塞语义。信号递送期间,当前信号是否自动加入屏蔽字,signal 给不了你选择权,而 sigaction 可以用 SA_NODEFER 精确控制。
第三,拿不到附加信息。sigaction 可以设置 SA_SIGINFO 拿到发送者的 pid、uid 和附加数据,排查问题时极其有用。
所以标准写法是:
#include <signal.h> #include <stdio.h> #include <string.h> static void handle_term(int signo, siginfo_t *info, void *context) { /* 只做最安全的事:置标志 */ extern volatile sig_atomic_t g_stop; g_stop = 1; } int install_handler(int signo) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_sigaction = handle_term; sa.sa_flags = SA_SIGINFO | SA_RESTART; sigemptyset(&sa.sa_mask); /* 处理期间额外屏蔽的信号,避免重入 */ sigaddset(&sa.sa_mask, SIGINT); sigaddset(&sa.sa_mask, SIGQUIT); if (sigaction(signo, &sa, NULL) < 0) { perror("sigaction"); return -1; } return 0; }3.2 sigaction 结构体逐字段拆解与参数依据
这个结构体看着杂,其实就四块内容,我把每个字段的坑都写出来。
sa_handler和sa_sigaction是 union,两者只能选一个。用了 SA_SIGINFO 标志就必须填 sa_sigaction。如果两个都填了,实际生效的是 sa_sigaction 那个。这个 union 的性质导致 memset 清零这一步不能省,我就见过因为没清零导致 sa_flags 里混进垃圾值、行为诡异的案例。
sa_mask是处理函数执行期间额外屏蔽的信号集。注意,当前正在处理的信号会自动加入屏蔽字(除非设了 SA_NODEFER),sa_mask 是在这个基础上追加的。我一般会把相关的控制类信号都加进去,比如处理 SIGTERM 时屏蔽 SIGINT 和 SIGQUIT,避免收尾逻辑被打断。但别屏蔽太多,尤其别屏蔽 SIGCHLD,否则子进程回收会延迟。
sa_flags是重点,几个高频标志必须搞清楚:
- SA_RESTART:让被信号打断的慢系统调用自动重启。这个标志是双刃剑。开了之后,read、write、accept 被打断会自动重来,代码省事;但如果你想用信号来唤醒阻塞在 read 上的循环,开了它就永远醒不了。我的做法是:默认开 SA_RESTART,需要靠信号唤醒的地方单独关掉,并且显式处理 EINTR。
- SA_SIGINFO:启用三参数版本的处理函数。
- SA_NODEFER:处理函数执行期间不自动屏蔽当前信号。除非你在做信号嵌套调试,否则别开。
- SA_RESETHAND:处理一次后恢复默认动作。等价于老式的一次性行为,慎用。
- SA_RESTART 之外还有 SA_NOCLDSTOP 和 SA_NOCLDWAIT,这两个专用于 SIGCHLD。SA_NOCLDWAIT 的效果等同于显式设置 SIG_IGN,子进程退出自动回收,但你就拿不到退出状态了。
sigemptyset(&sa.mask)这一步千万别漏。结构体没初始化就调 sigaction,sa_mask 里是什么内容完全看栈上的残留数据,可能把 SIGTERM 给屏蔽了,然后你的进程就杀不掉了——这个现象我遇到过,排查的时候一度以为是内核问题,纯属自己坑自己。
3.3 信号屏蔽字的三种操作模式
sigprocmask的 how 参数有三个值,很多人只会用 SIG_BLOCK,其实另外两个在特定场景下更重要。
SIG_BLOCK 是"把参数里的信号加进屏蔽字",用于临界区保护。比如你在更新一个共享链表,不希望中间被打断,就在操作前后成对使用:
sigset_t block, old; sigemptyset(&block); sigaddset(&block, SIGTERM); sigaddset(&block, SIGCHLD); sigprocmask(SIG_BLOCK, &block, &old); /* 临界区:更新共享状态 */ sigprocmask(SIG_SETMASK, &old, NULL); /* 注意这里用 SETMASK 恢复 */注意恢复的时候要用 SIG_SETMASK 传回之前保存的 old,而不是用 SIG_UNBLOCK,因为 UNBLOCK 只会移除你传入的信号,不会恢复到调用前的精确状态。如果临界区嵌套了,用 UNBLOCK 会导致外层屏蔽被提前解除。
SIG_SETMASK 是"直接把屏蔽字设成参数里的内容",用于初始化和恢复,是最常用的那个。
SIG_UNBLOCK 是"从屏蔽字里移除参数里的信号",用于主动打开某个信号的递送。有个细节值得说:这个操作在信号已经 pending 的情况下,会立刻触发递送,也就是说sigprocmask(SIG_UNBLOCK, &set, NULL)这一行返回之后,处理函数可能已经执行完了。
实操提示:在多线程程序里,sigprocmask 的行为是未定义的,应该用 pthread_sigmask。两者参数完全一样,但后者只影响当前线程,符合线程语义。
3.4 可重入性与 errno 保护的实操写法
信号处理函数里能安全调用的只有异步信号安全(async-signal-safe)的函数,这份名单在 man 7 signal 里有,核心是 read、write、open、close、_exit、kill、sigaction 这些系统调用,加上少量简单函数。printf、malloc、free、strtok、getpwnam 全都不在名单里。
更隐蔽的问题是errno。处理函数可能在你刚好调用某个库函数失败、准备读 errno 的时候插进来,处理函数内部又调了别的系统调用把 errno 覆盖了,等你回来读到的就是错误的错误码。标准做法是在处理函数开头保存、结尾恢复:
static void safe_handler(int signo) { int saved_errno = errno; /* 只做置标志或写管道 */ g_flag = 1; errno = saved_errno; }这个细节在 99% 的代码里都缺失,平时也不会有事,一旦出问题就是那种"日志显示 errno 是 EINTR 但代码里根本没有产生 EINTR 的地方"的幽灵 bug。
再强调一遍全局变量的类型:处理函数和主流程共享的变量必须声明为volatile sig_atomic_t。volatile 是防止编译器把它缓存到寄存器里导致主循环读不到更新,sig_atomic_t 保证读写是单条指令级的原子操作。注意这只保证单次读写原子,不保证counter++原子,要做计数或复杂结构请用管道或 signalfd。
4. 一套可复用的信号处理框架落地实录
4.1 需求定义与方案选型对比
框架要解决的问题是:一个长期运行的后台服务,需要响应 SIGTERM 做优雅退出、响应 SIGHUP 重载配置、响应 SIGCHLD 回收子进程,同时整体架构要基于 epoll 做 IO 多路复用。
围绕这个需求我评估过三种方案:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 纯 handler | sigaction 注册,处理函数里置标志位 | 简单直接,无额外 fd | 处理函数上下文受限,无法做复杂逻辑,标志位轮询有延迟 |
| self-pipe | 处理函数只写管道,主循环读管道 | 把异步转同步,逻辑可放主流程,兼容性好 | 需要额外两个 fd,处理函数里写管道仍有 pipe 满风险 |
| signalfd | 把信号集转成 fd,直接挂 epoll | 完全统一进事件循环,无异步上下文问题,可精确控制屏蔽 | Linux 专有,需要一开始就屏蔽信号 |
我的选择是signalfd 为主,self-pipe 作为兜底。主力逻辑走 signalfd,因为整个服务本来就是 epoll 驱动,多挂一个 fd 是零成本的;同时保留一个极简 handler 版本,用于那些不方便改造成 epoll 的小工具。
4.2 完整代码实现与逐段注释
先看核心结构:
#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <unistd.h> #include <signal.h> #include <sys/signalfd.h> #include <sys/epoll.h> #include <sys/wait.h> static volatile sig_atomic_t g_stop = 0; static volatile sig_atomic_t g_reload = 0; /* 兜底 handler:只置标志,绝不调用非异步安全函数 */ static void fallback_handler(int signo) { int saved = errno; if (signo == SIGTERM || signo == SIGINT) { g_stop = 1; } else if (signo == SIGHUP) { g_reload = 1; } errno = saved; } /* * 创建 signalfd 并屏蔽对应信号。 * 关键:必须先 sigprocmask 屏蔽,再创建 signalfd, * 否则信号会在两者之间被递送到默认动作。 */ static int setup_signalfd(sigset_t *out_mask) { sigset_t mask; sigemptyset(&mask); sigaddset(&mask, SIGTERM); sigaddset(&mask, SIGINT); sigaddset(&mask, SIGHUP); sigaddset(&mask, SIGCHLD); if (sigprocmask(SIG_BLOCK, &mask, NULL) < 0) { perror("sigprocmask"); return -1; } int fd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC); if (fd < 0) { perror("signalfd"); return -1; } if (out_mask) *out_mask = mask; return fd; }下面是主事件循环里处理信号的部分。epoll 返回后,如果命中的是 signalfd,就用一个循环把队列里所有信号读干净——注意这里必须循环读,因为一次 read 只返回一个信号,队列里可能积压多个:
static int handle_signal_fd(int sfd) { struct signalfd_siginfo si; for (;;) { ssize_t n = read(sfd, &si, sizeof(si)); if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; perror("read signalfd"); return -1; } if (n != sizeof(si)) break; switch (si.ssi_signo) { case SIGTERM: case SIGINT: fprintf(stderr, "[signal] 收到终止请求, signo=%u, pid=%u\n", si.ssi_signo, si.ssi_pid); g_stop = 1; break; case SIGHUP: fprintf(stderr, "[signal] 收到重载请求\n"); g_reload = 1; break; case SIGCHLD: /* 循环 waitpid,一次信号可能对应多个子进程退出 */ for (;;) { int status = 0; pid_t pid = waitpid(-1, &status, WNOHANG); if (pid <= 0) break; fprintf(stderr, "[signal] 回收子进程 pid=%d status=%d\n", pid, status); } break; default: break; } } return 0; }SIGCHLD 这里有个极其常见的错误:只调一次 waitpid。因为标准信号会合并,三个子进程同时退出可能只产生一次 SIGCHLD,你只回收一个就漏两个。必须循环 waitpid 直到返回 0 或 -1 为止,这个写法能覆盖绝大多数情况。
4.3 编译、运行与验证的完整流程
编译时注意_GNU_SOURCE必须在所有头文件之前定义,否则 signalfd 相关声明不可见:
gcc -O2 -Wall -Wextra -o sigdemo sigdemo.c验证分几步做,每步都有明确的观察点:
第一步,验证优雅退出。启动程序,另开终端kill -TERM <pid>,观察 stderr 是否打印了"收到终止请求"以及 ssi_pid 是不是发送者的 pid。这一步能确认 signalfd 链路通了。
第二步,验证信号不被误杀。在程序里加一个模拟长任务的 sleep,启动后立刻发 SIGTERM,确认进程不是被默认动作干掉,而是走完了收尾逻辑才退出。如果进程直接消失、没打印任何东西,说明屏蔽没生效,信号在创建 signalfd 之前就被递送了。
第三步,验证 SIGCHLD 批量回收。写一段循环 fork 十个子进程、每个 sleep 极短时间的代码,观察 stderr 是否把十个 pid 全部打出来。如果只打了一两个,就是 waitpid 没循环的问题。
第四步,验证信号不丢失。连续快速发送 20 个 SIGHUP,观察处理次数。标准信号本来就允许合并,所以处理次数小于 20 是正常的,但如果你的业务要求每个都不能丢,这里就是换实时信号的信号。
这套验证流程我每次改造信号相关代码都会走一遍,五分钟的事,能挡掉后面几小时的排查。
4.4 框架的可扩展点与改造建议
这套骨架落地之后,有几处可以按需扩展。
一是退出超时控制。收到 SIGTERM 后开始收尾,但要防止收尾逻辑自己卡死。我的做法是收尾开始时启动一个定时器(timerfd 或者 alarm),超时还没退完就_exit(1)强制退出。这个兜底在线上救过我好几次,尤其是等锁或者等网络超时的时候。
二是信号来源校验。signalfd_siginfo 里有 ssi_uid 和 ssi_pid 字段,对于 SIGHUP 这种能触发重载配置的信号,可以校验发送者 uid 是不是 root 或者自己,防止被同机器上的其他用户误触。
三是把定时任务也并进来。用 timerfd 代替 SIGALRM,和 signalfd 一起挂 epoll,整个程序就没有任何异步上下文了,所有逻辑都在主循环里线性执行,调试的时候打日志顺序完全可预测,这对排查并发问题帮助极大。
四是配置热重载的原子性。SIGHUP 触发重载时,新配置解析失败要保留旧配置,不能解析一半就替换。我一般用"解析到临时结构体、校验通过、原子切换指针"这个模式,切换过程用信号屏蔽字保护一下。
5. 高频故障现场与排查手册
5.1 信号"丢"了:从合并机制找根因
现象是通知发了但处理没执行,或者执行次数少于发送次数。根因九成是标准信号的合并特性。排查顺序:先确认发的是不是标准信号,如果是,就看代码里是否有"每个信号必须处理"的假设。
有个更隐蔽的情况:信号在阻塞期间被多次产生,解除阻塞时只有一个被递送,但这一个递送会触发一次处理。所以如果你的逻辑是"收到一次信号处理一个任务",而任务的产生速率高于处理速率,积压的任务就永久丢了。
解决办法互相之间不通用,要按场景选:任务类改用实时信号SIGRTMIN+n配 sigqueue,能排队;或者干脆弃用信号,改用管道/消息队列传数据,信号只做唤醒。
还有一种"丢"是假丢:处理函数里调用了非异步安全函数,导致死锁,进程看起来像没响应。判断方法是看进程状态,如果卡在 futex 或者某个锁上,基本就是这个问题。
5.2 系统调用被打断:EINTR 的正确处理姿势
几乎所有慢系统调用(read、write、accept、select、poll、nanosleep、wait)都可能被信号打断并返回 -1,errno 设为 EINTR。处理方式取决于你的意图。
如果被打断是意外,你希望调用继续完成,那就开 SA_RESTART。但要注意 SA_RESTART 不是万能的,它只对部分系统调用生效,而且对select、poll这类在某些情况下不重启。所以我更推荐的做法是统一手动处理 EINTR:
ssize_t n; do { n = read(fd, buf, sizeof(buf)); } while (n < 0 && errno == EINTR);这个循环看着笨,但它是唯一在所有平台上都可靠的方式。而且它能配合"用信号唤醒阻塞读"的设计——如果你想让信号打断读操作去做别的事,就不能开 SA_RESTART,然后在这个 EINTR 分支里跳出去处理别的逻辑。
反过来的坑也要提:如果 errno 被信号处理函数覆盖了(前面讲的 errno 保护没做),你的while (errno == EINTR)判断可能失效,循环条件变成随机值。这种 bug 极难复现,所以那两行 errno 保存恢复,真的别省。
5.3 SIGCHLD 与僵尸进程:一个被误解的默认行为
僵尸进程的成因是子进程退出后父进程没有 wait。而 SIGCHLD 的默认动作是"忽略",这个"忽略"让人误以为内核会自动清理,实际上并不会——默认忽略仍然会产生僵尸,只有显式调用 sigaction 把 handler 设为 SIG_IGN,或者设置 SA_NOCLDWAIT 标志,内核才会自动回收。
所以你有三个选择,各有取舍:
| 做法 | 是否产生僵尸 | 能否拿到退出状态 | 适用场景 |
|---|---|---|---|
| 默认(不处理 SIGCHLD) | 会 | 可以,自己 wait | 需要精确知道子进程结果 |
| sigaction 设 SIG_IGN | 不会 | 不能 | 完全不在意子进程结果 |
| handler + 循环 waitpid | 不会 | 可以 | 推荐,兼顾两者 |
第三种是通用做法,但要注意 waitpid 的参数必须是 WNOHANG,否则会阻塞在 handler 里,把主流程拖住。还要处理一种特殊情况:如果父进程自己在 handler 执行期间又被 SIGCHLD 打断(虽然标准信号在处理期间被自动屏蔽,不会重入),或者子进程在 waitpid 返回 0 之后、下一次检查之前退出,那个 SIGCHLD 是挂起的,会被下一次递送处理掉,不会漏。
还有一个容易忘的点:fork 之前如果父进程屏蔽了 SIGCHLD,子进程会继承这个屏蔽字。子进程 exec 之后屏蔽字仍然保留(屏蔽字在 exec 时不重置),可能导致子进程程序里收不到 SIGCHLD。所以 fork 之后、exec 之前,该恢复的屏蔽字要恢复。
5.4 多线程下信号到底进了哪个线程
这个问题问的人特别多。答案分两部分。
递送规则:进程级信号(kill 发的)会递送给进程内任意一个没有屏蔽该信号的线程,具体是哪个由内核调度决定,不可预测。线程级信号(pthread_kill 发的)递送给指定线程。硬件异常类信号(SIGSEGV、SIGFPE)递送给触发异常的那个线程。
控制手段:如果想让某个专用线程处理所有信号,标准做法是在创建线程之前,由主线程把所有关心的信号阻塞掉,然后创建出来的线程全部继承这个屏蔽字(屏蔽字在 pthread_create 时被继承)。最后单独创建一个信号处理线程,在这个线程里解除屏蔽,或者直接在这个线程里调 sigwait 等待。这样所有信号就只会落到这一个线程上,其他线程完全不受干扰。
/* 主线程:先屏蔽 */ sigset_t set; sigemptyset(&set); sigaddset(&set, SIGTERM); pthread_sigmask(SIG_BLOCK, &set, NULL); /* 创建若干工作线程,它们都会继承屏蔽字 */ for (int i = 0; i < N; i++) { pthread_create(&tid[i], NULL, worker, NULL); } /* 信号处理线程:循环 sigwait */ void *sig_thread(void *arg) { sigset_t *pset = arg; int sig; for (;;) { if (sigwait(pset, &sig) == 0) { /* 处理 sig */ } } return NULL; }用 sigwait 的好处是它是同步等待,返回后你可以随便调任何函数,完全没有异步上下文限制。这是我目前最推荐的多线程信号处理方案,比 signalfd 还要简单(后者需要 epoll 配合)。
顺带说一句,pthread_sigmask 在子线程里调只影响该线程,主线程调会影响之后创建的线程(通过继承),但对已经存在的线程没影响。所以屏蔽动作必须在创建线程之前做,顺序错了就不生效。
5.5 排查速查表
把上面这些整理成一张表,出问题时按行对照:
| 现象 | 最可能的原因 | 快速验证方法 | 解决方向 |
|---|---|---|---|
| 进程无故消失,无日志 | 未处理 SIGPIPE / SIGTERM 默认终止 | strace 看最后收到的信号 | 屏蔽或捕获对应信号 |
| 信号处理函数不执行 | 被阻塞且未解除 | 查看 /proc/pid/status 的 SigBlk | sigprocmask 解除 |
| 处理次数少于发送次数 | 标准信号合并 | 换实时信号测试 | 改用实时信号或换通信方式 |
| 进程卡死不响应 | handler 内死锁(调了非安全函数) | gdb 看线程栈 | handler 只置标志 |
| 子进程变僵尸 | 未 wait 或只 wait 一次 | ps 看 Z 状态进程 | 循环 waitpid |
| read 返回 -1 且 errno 异常 | errno 被 handler 覆盖 | 检查是否保存恢复 errno | 加 errno 保护 |
| 屏蔽了信号但照样被杀 | SIGKILL/SIGSTOP | 看信号编号 | 这两个无法屏蔽,改设计 |
6. 进阶玩法与个人避坑清单
6.1 把信号彻底变成文件事件:signalfd 的细节考量
signalfd 的价值在于消灭异步上下文。用上它之后,整个程序只有一条执行流,所有事件都从 epoll 出来,日志顺序和代码顺序完全一致,调试体验的提升是质的。但用之前有几件事必须明确。
屏蔽必须先行。sigprocmask 要在 signalfd 创建之前调用,否则在两者之间的窗口期信号会被递送到默认动作。这个顺序我在代码里注释了三遍,因为改代码的人很容易把两行调换。
读取必须循环。一次 read 只吐出一个信号,队列里积压多个时 epoll 是水平触发的话会反复通知,但如果是边沿触发(EPOLLET)而你不读完,后面的就再也不会通知了。所以无论哪种触发模式,都该读到 EAGAIN 为止。
默认动作不再生效。信号被 signalfd 接管后,就算你不处理,进程也不会按默认动作终止了。这意味着 SIGTERM 不再能杀掉你的程序——听起来是好事,实际上是风险。如果事件循环本身卡住,你连 kill 都杀不掉,只能 kill -9。所以我在框架里加了一条:收到 SIGTERM 后启动 10 秒的强制退出定时器。
fd 的生命周期。signalfd 返回的 fd 是用 -1 作为入参创建的,不需要预先打开文件。用完 close 即可,close 之后信号会重新回到普通递送流程(如果还阻塞着就继续 pending)。多进程场景下,fork 出来的子进程会继承这个 fd,通常应该在子进程里关掉它,避免意外消费父进程的信号。
6.2 定时器信号该不该用:SIGALRM 的替代方案
早期写定时任务都用 alarm 或者 setitimer 配 SIGALRM,现在我不推荐了。原因有三:一是 alarm 精度只有秒级;二是整个进程只有一个 alarm,多个定时需求会互相覆盖;三是它又是异步上下文,处理函数里干不了正事。
替代方案首选 timerfd。它把定时器变成了一个可读 fd,到期时 fd 可读,读出来是到期的次数。配合 epoll 使用,和网络 IO 完全统一。精度可以到纳秒级,还支持周期触发和一次性触发:
#include <sys/timerfd.h> int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); struct itimerspec its; its.it_value.tv_sec = 1; /* 首次触发 1 秒后 */ its.it_value.tv_nsec = 0; its.it_interval.tv_sec = 1; /* 之后每 1 秒一次 */ its.it_interval.tv_nsec = 0; timerfd_settime(tfd, 0, &its, NULL);读的时候要注意,返回值是 uint64_t,表示自上次读以来到期了几次。如果处理太慢导致多次到期,这个数字会大于 1,你得决定是补跑还是跳过。我一般选择跳过并记一条日志,因为补跑往往会让积压越来越严重。
如果程序规模小、不想引入 epoll,那 nanosleep 循环或者 clock_nanosleep 也比信号定时器好,至少它是同步的、可中断的、能拿到剩余时间。
6.3 SIGSEGV 能不能捕获:一个需要克制的场景
技术上 SIGSEGV 可以捕获,捕获之后进程不会立刻死,理论上还能继续跑。但我要说的是:生产代码不要试图从 SIGSEGV 里恢复。段错误意味着内存状态已经损坏,继续跑下去的行为完全不可预测,可能写坏更多数据。
捕获 SIGSEGV 唯一合理的用途是留下现场:在处理函数里用 write 把关键信息(出错地址、寄存器快照)写到预先打开的 fd 里,然后立刻 _exit 或者重新触发默认动作。注意这里不能用 printf,因为堆可能已经坏了;也不能用 exit,因为它会跑 atexit 回调和刷新 stdio 缓冲区,同样可能再崩一次。正确做法是_exit(128 + SIGSEGV)。
还有个小技巧:用 sigaltstack 给信号处理函数准备独立的栈。因为栈溢出导致的 SIGSEGV,原来的栈已经没法用了,处理函数根本跑不起来。配 SA_ONSTACK 标志就能用备用栈,这样至少能留下日志。
6.4 我在实际项目里踩过的坑与经验条目
最后把这些年真正让我加过班的经验列出来,都是文档里不写的。
第一条,SIGPIPE 必须处理,而且要在最外层处理。网络服务里写一个已经对端关闭的 socket,内核会先发 SIGPIPE 再返回 EPIPE。默认动作是终止进程,你的服务就这么没了。做法是在 main 开头signal(SIGPIPE, SIG_IGN),然后靠 write 返回的 EPIPE 判断。注意这个忽略会影响之后 fork 的子进程,子进程里如果要用默认行为,得自己恢复。
第二条,别在 handler 里用 stdio 的任何函数。我被 printf 坑过一次,现象是进程偶发卡死,gdb 一看卡在 stdout 的锁上。原因是主线程正在 printf 输出大段内容,信号插进来,handler 又调 printf,同一把锁重入死锁。换成 write(STDERR_FILENO, ...) 之后问题消失。而且 stderr 本身是无缓冲的,用 write 更可控。
第三条,处理函数里的变量一定要 volatile sig_atomic_t。我见过编译器把标志位优化到寄存器里,导致主循环永远看不到更新的案例。加了 volatile 就好了,代价是每次访问都从内存读,但这点的性能损失在信号场景下完全可以忽略。
第四条,测试的时候要覆盖"信号在临界区到达"的情况。正常路径测一百次都没事,不代表没问题。我的做法是写一个压力测试程序,用一个线程疯狂发信号,另一个线程疯狂执行业务逻辑,跑十分钟。这个测试能揪出大部分竞态和重入问题,比代码 review 有效得多。
第五条,善用 /proc/pid/status 排查。里面 SigPnd、ShdPnd、SigBlk、SigIgn、SigCgt 五个字段分别表示线程待处理、进程待处理、被阻塞、被忽略、被捕获的信号集,十六进制位图。进程行为异常的时候,先看一眼这个,能快速排除一大半可能性。比如 SigBlk 里如果有 SIGTERM,你就知道为什么杀不掉了。
第六条,跨进程的信号语义不要想当然。kill 返回 0 只代表信号进入了目标进程的 pending 集,不代表它被处理了。如果你需要确认对方处理完了,得用别的机制(管道回执、共享状态)来同步,信号本身不提供任何确认语义。
第七条,把信号处理的复杂度前置到设计阶段。我接手过几个项目,信号逻辑散落在十几个文件里,每个模块自己注册 handler,互相覆盖,最后 sa_flags 是什么完全靠运气。后来统一改成"集中注册、单一入口、事件分发"的结构,可维护性提升非常明显。这件事在项目初期做成本极低,做晚了就是重构。
我个人在实际操作中的体会是,信号处理这块知识真正的门槛不在 API,而在对"异步"这个概念的理解深度。当你写每一行 handler 代码的时候,脑子里都能浮现出"这行可能在主流程的任何一个位置插入执行"这个画面,你自然就不会犯那些低级错误了。前面那套框架和清单,本质上就是把这种警惕性固化成了代码结构和规范,让团队里经验不足的同学也不容易写错。