☰
Linux信号处理实战:从阻塞未决到多线程安全与signalfd
2026/9/28 5:42:49 网站建设 项目流程

上篇我们聊完了 Linux 进程信号怎么发、怎么接,也写了不少能跑的 handler 示例。但说句实在话,那种程度只够应付课程设计和面试八股。真到了维护一个多线程 RPC 服务、或者调一个“信号发过去但进程装死”的线上问题,你很快会被几个问题卡住:为什么我在 handler 里调 printf 偶发会崩?为什么信号在多线程进程里像乱枪打鸟一样没准头?为什么实时信号和普通信号的脾气完全不同?

这篇我打算接着上篇往下写,专门讲讲信号在真实项目里最容易出事的几个面:阻塞与未决、异步安全、多线程路由、实时信号,以及 signalfd 这种把信号当文件读的现代做法。纯 demo 代码我不多给,重点放在“为什么要这么做”和“不这么做会有什么后果”上。做后台服务的同学,建议至少把第 2 节和第 3 节读两遍,那两节的内容几乎算得上线上血泪史。

1. 信号不是队列,而是一张“待办位图”

1.1 阻塞与未决:内核到底记了几份信号

每个进程都有两个关键集合:阻塞信号集和未决信号集。我常跟组里新人打比方:阻塞信号集是门口的筛子,未决信号集是信箱。信号产生后先塞进信箱,如果这个信号没被筛子挡住,内核立刻让你处理;如果被挡住了,信就躺在信箱里不动,等到筛子松开,那封信才会被拿出来处理。

这个流程里最反直觉的地方在于:标准信号在未决信号集里只是一个 bit,不是一条链表。也就是说,内核不会为同一个标准信号记录“来了几次”,只会记录“有没有来过”。这跟日常生活中的快递不一样,快递柜能存好多件,信号信箱里同一个格子只能亮一盏灯。这样设计的原因很简单,标准信号从诞生起就只是一种“提醒机制”,不是可靠的数据通道。

所以,当你连续给某个进程发送 10 次 SIGTERM,而这个进程恰好一直阻塞着 SIGTERM,最终它解除阻塞后只会处理一次。很多第一次接触的人会惊掉下巴:还有这种事?对,就是有。这也是 SIGUSR1、SIGUSR2 这类信号永远只适合做“唤醒”,不适合做“计数”的根本原因。

1.2 信号合并带来的经典设计:信号只管通知,状态自己查

既然信号会合并,那该怎么可靠地用信号做事件通知?业界标准答案是:收到信号后去查共享状态,而不是依靠信号的“次数”。举个最常见的 worker 场景:

sigset_t set; sigemptyset(&set); sigaddset(&set, SIGUSR1); sigprocmask(SIG_BLOCK, &set, NULL); for (;;) { sigwait(&set, &sig); if (sig == SIGUSR1) { // 别在这里数次数,去任务队列里把所有任务都捞出来 while ((task = dequeue()) != NULL) { process(task); } } }

这个模型在很多守护进程里都能看到。任务入队的人负责向队列加数据并发送一个 SIGUSR1;worker 线程通过 sigwait 被唤醒,唤醒后不关心 wakeup 多少次,只关心队列里到底有多少活。哪怕持有者和 worker 之间信号合并了,任务也不会丢,因为任务本身放在队列里,信号只是“按门铃的人”。

如果反过来,你想用信号来传递“增量计数”,比如来一个请求信号计一次数,标准信号必然会在高频时丢数。这个时候你要么用共享内存加原子变量自己管理计数,要么老老实实改走消息队列、eventfd 或者实时信号,后者的语义才支持排队。

1.3 用 sigprocmask 管理临界区的正确姿势

理解了信号是位图,下一个实际问题是:如何在临界区里临时屏蔽某些信号?最典型的场景是写一个多线程的引用计数或计数器,你不想在更新中间被 SIGINT 打断,导致状态半更新。

正确流程是:先构造新的屏蔽集,用sigprocmask(SIG_BLOCK, ...)叠加屏蔽,保存旧状态;临界区做完后,用SIG_SETMASK把旧状态完全恢复。下面这段代码是我项目里的标准写法:

sigset_t block_set, old_set; sigemptyset(&block_set); sigaddset(&block_set, SIGINT); // 保存旧掩码,阻塞 SIGINT if (sigprocmask(SIG_BLOCK, &block_set, &old_set) < 0) { perror("sigprocmask block"); return -1; } // 临界区,不希望被 SIGINT 打断 update_shared_counter(); // 恢复原掩码,不要用 SIG_UNBLOCK 单独解掉 SIGINT if (sigprocmask(SIG_SETMASK, &old_set, NULL) < 0) { perror("sigprocmask restore"); return -1; }

这里有两个容易踩的坑。第一个坑是只记得SIG_UNBLOCK解除 SIGINT,忘了考虑调用进来之前 SIGINT 本来是否就已经被阻塞了。如果原线程本来就阻塞 SIGINT,你临界区结束后把它解掉,反而让后面的代码暴露在了本不该响应的信号下。第二个坑是新手喜欢自己从零造一个 mask,而不是保存 old_set,结果把线程原有的屏蔽信息给覆盖了。无论临界区多短,都要保持“保存旧掩码、恢复旧掩码”的习惯。在多线程环境里,sigprocmask的行为未定义,标准做法是换pthread_sigmask,这个我们在第 3 节细讲。

2. 处理函数远比想象中危险:异步信号安全这条红线

2.1 一个看着正常却会随机崩溃的 handler

如果你写过 C 程序处理信号,第一次写的 handler 十有八九长这样:

void handler(int sig) { printf("got signal %d\n", sig); }

单独看逻辑没有任何问题,可它随时可能把整个进程搞崩。原因是:信号处理函数会在进程执行到任意一条指令时被插入,这个位置是不确定的。如果你恰好正在调用printf,printf内部已经申请了stdout的锁、修改了缓冲区指针,此时被信号打断,handler 里又调一次printf——同一个线程,同一个锁,同一个不可重入的缓冲结构,第二次调用就可能看到第一次调用留下的中间状态。轻则输出错乱,重则直接触发断言或者死锁。

换句话说,handler 运行在异常异步上下文里,能不能调用某个函数,不是看“这个函数本身稳不稳定”,而是看“这个函数是否异步信号安全(async-signal-safe)”。POSIX 规定,在 handler 里只能调用一小撮系统调用和库函数。write是安全的,read是安全的,open、close、waitpid、sigaction也是安全的;而printf、malloc、free、pthread_mutex_lock、rand这些统统不在保证范围内。你没法用printf在 signal handler 里调试,能干的就是:

void handler(int sig) { const char msg[] = "got signal\n"; write(STDERR_FILENO, msg, sizeof(msg) - 1); }

自己用write拼字符串虽然丑,但至少不会踩内存管理或线程锁的雷。真要想正经记录日志,把 fd 用snprintf格式化好后一次write也可以,但别在 handler 里调用任何会分配内存的函数。

2.2 errno 也要保护:handler 是一个完全独立的世界

这一节是最容易被忽略的。有些函数确实在异步信号安全列表里,但它们会设置errno。比如write遇到非阻塞 fd 可能返回 EAGAIN,此时全局变量errno被改成 EAGAIN。如果主程序在某一时刻正在执行read(fd, buf, len); if (errno != EINTR) ...,读取系统调用返回后errno还没被检查,结果信号 handler 里的write把errno改了,主程序拿到的错误码就是脏的。

我踩过一次真实问题:一个网络库在某次信号到达后,把所有客户端连接都误判成“对端关闭”,日志里全是 EAGAIN,但业务本身完全正常。查了半天才定位到,是 handler 里的 I/O 把errno覆盖了。

所以,只要 handler 里调用了可能修改errno的函数,第一步就要保存errno,退出前恢复:

void handler(int sig) { int old_errno = errno; const char msg[] = "signal handled\n"; write(STDERR_FILENO, msg, sizeof(msg) - 1); errno = old_errno; }

别觉得这个细节无足轻重。在高并发、长时间运行的服务里,任何无锁修改全局状态的代码都是隐患。这个习惯花不了几个周期,但能省掉好几个通宵。

2.3 唯一能安全共享的变量:volatile sig_atomic_t

主程序和 handler 之间总要通信,最简单的“优雅退出”模式就是:handler 里置一个标志位,主循环轮询它。这个标志位的类型和修饰符非常有讲究。

static volatile sig_atomic_t g_stop = 0; void handler(int sig) { g_stop = 1; } int main(void) { struct sigaction sa; sa.sa_handler = handler; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; sigaction(SIGINT, &sa, NULL); while (!g_stop) { do_work(); } }

volatile防止编译器把这个变量优化到寄存器里,否则主循环可能一直看不到 handler 的修改;sig_atomic_t是一个被保证读写原子性的整数类型,保证处理函数和主循环之间对该变量的访问不会看到半更新的状态。在 C 标准里,能保证的只有这种整型的原子性,不能保证uint64_t、不能保证结构体,更不能保证某个自定义类。你要是听别人说“在 handler 里写一个指针没问题”,那绝不是通用保证,十有八九是某个平台的偶然现象。

其实我还想提醒一点:即便用volatile sig_atomic_t能应付简单的退出标志,但它不适合作为线程间同步手段。主循环轮询标志,延迟就是不可控的。更稳的方案是用self-pipe trick或者第 5 节要讲的signalfd,把信号转成一个 fd 的可读事件,插进 epoll 事件循环里处理。后面会展开。

3. 多线程下的信号路由:信号到底发给了哪个线程

3.1 进程定向信号与线程定向信号

很多多线程程序的崩溃,不是业务代码并发问题,而是信号投递的“随机性”导致的。kill(pid, sig)发出的信号是进程定向信号,内核会在进程中挑一个不阻塞该信号的线程来投递,具体挑哪个线程由内核调度决定,不一定是你期望的主线程。

想象一下这个场景:线程 A 正在持有一把业务锁,执行关键区;此时你从外部kill发了一个 SIGTERM,内核挑中了线程 A,进入 handler。如果 handler 里恰好调用了某个会锁同一个锁的函数,直接死锁;handler 里就算不主动加锁,printf这类 glibc 函数内部也有锁,一样死锁。内核不会聪明到“挑一个没持锁的线程”,它只看“这个线程是否阻塞了该信号”,其他一概不管。

想定向处理线程,有pthread_kill(thread, sig)或tgkill,可以精确发给指定线程。但如果你的设计是“某个线程专用处理信号”,对应的步骤必须清晰:既要保证其他线程不抢信号,也要保证专用线程一定能收到。这就需要成套的信号屏蔽管理。

3.2 推荐的线程化信号模型:sigwait + 专用线程

处理多线程信号的公认最佳实践是:进程启动早期,在所有工作线程创建之前,先用pthread_sigmask把要处理的信号全部阻塞;然后专门创建一个线程,用sigwait等待这些信号并以同步方式处理。好处很明显:sigwait是普通上下文,不是异步上下文,你可以放心调用printf、malloc、加锁、甚至做长时间日志清理,不用担心信号安全红线。

sigset_t waitset; sigemptyset(&waitset); sigaddset(&waitset, SIGINT); sigaddset(&waitset, SIGTERM); // 必须在创建工作线程之前调用,这样所有子线程都继承阻断状态 pthread_sigmask(SIG_BLOCK, &waitset, NULL); pthread_create(&worker_thread_id, NULL, worker_main, NULL); // 专用信号线程 void *signal_thread(void *arg) { int sig; for (;;) { int ret = sigwait(&waitset, &sig); if (ret != 0) { continue; } if (sig == SIGINT || sig == SIGTERM) { printf("shutdown requested\n"); shutdown_all(); break; } } return NULL; }

关键点在于pthread_sigmask的调用时机。线程的阻塞信号集是从创建它的线程继承的,如果你先创建了三个工作线程,然后再在专用线程里屏蔽信号,那么先前创建的那些工作线程完全不受保护,信号照样可能在它们执行到一半时插入,你这个模式就白搭了。正确顺序永远是在所有线程创建之前,先设好屏蔽字。

另外,sigwait成功后,该信号已经从进程的未决信号集中移除,因此专用线程不需要注册 handler,也不会有异步上下文。这比signal/sigaction方案维护起来舒服得多。

3.3 用 pthread_kill 定向投递时,记得判断归属

有时你并不想让专用线程处理一切,而是希望某个特定线程收到某个信号,比如让 IO 线程感知连接超时信号。Linux 上可以用pthread_kill(thread, sig)。但要注意,目标线程必须没有阻塞该信号,否则信号会挂在它的 pending 集里,什么时候处理要看后续是否解除阻塞。

有个容易踩的坑:pthread_kill返回 0 只代表“内核已经登记了信号”,并不代表“线程已经执行了 handler”。如果你用它来做线程心跳检测,必须在 handler 里回写标志或者通过管道报知,否则发完信号后立刻检查“线程还活着吗”是看不出来的。实际写代码时我通常会配合一个带超时的等待变量:发信号,然后等最多几百毫秒看 handler 是否把标志位置位,超时再判定异常。这样至少比盲等无响应可靠一点。

4. 实时信号:需要排队和带参数的通信场景

4.1 标准信号不排队,实时信号才排队

标准信号有一个天然缺陷:不排队,会合并。如果你需要传递“发生了多次事件”的信息,或者每次信号都能被处理到,必须用实时信号。Linux 上从SIGRTMIN到SIGRTMAX是一组实时信号,它们在内核里是真真正正挂进队列的。

实时信号的语义比标准信号硬核得多:一是支持排队,同一个实时信号连续发送 N 次,接收方会依次收到 N 次,不会合并;二是发送时可以携带一个整数或者指针,接收方通过siginfo_t拿到;三是多个实时信号之间有优先级的区别,编号小的优先投递。这个特性让它很适合做轻量级进程间通信。

我最初接触实时信号时以为直接填SIGRTMIN就行,后来看 glibc 文档才反应过来:SIGRTMIN在某些线程库实现里已经被“保留”了几个用于内部机制,比如 NPTL 内部会使用两个实时信号。所以实际项目里,如果不想和 libc 内部打架,习惯上从SIGRTMIN + 1开始用。另外,不同架构下 SIGRTMIN 数值不同,千万别在代码里写死 34 或 35。

4.2 sigqueue 发送 siginfo_t:不只是发一个编号

实时信号的价值在收发双方配合时最能体现。发送方用sigqueue:

union sigval sv; sv.sival_int = 2024; sigqueue(target_pid, SIGRTMIN + 1, sv);

接收方用带SA_SIGINFO的sigaction注册,handler 变成三段参数:

void rt_handler(int sig, siginfo_t *info, void *context) { int value = info->si_value.sival_int; // 处理 value } struct sigaction sa; sa.sa_flags = SA_SIGINFO; sa.sa_sigaction = rt_handler; sigemptyset(&sa.sa_mask); sigaction(SIGRTMIN + 1, &sa, NULL);

这里siginfo_t就是那扇门:si_value可以把整数或指针带过去,si_pid和si_uid能帮你确认发送方身份。实现中要小心一点:如果发送的是指针,接收方进程必须对同一块内存可见。父子进程刚 fork 完没问题,但两个不相干进程是不能直接传指针的,这时应当传整数,或者用共享内存/消息队列来承载真正要传的数据。信号本身当通知,数据放别处,这是移植性最好的一套组合。

4.3 实时信号也有资源上限,发送前做好保护

实时信号虽然可靠,但不等于无限。Linux 对“未决的实时信号数量”是有限制的,相关限制在RLIMIT_SIGPENDING里。如果用sigqueue发送太多实时信号,而接收方处理不过来,系统就报EAGAIN。所以实时信号照样不能当消息队列无脑灌,它同样只适合低频通知。

另外还要记住:实时信号和标准信号在阻塞语义上相同,如果被阻塞,也会进 pending 队列排队;一旦解除阻塞,按队列顺序投递。这个设计比标准信号的位图要舒服,但也不是完全无限缓冲区。真需要高频可靠传递业务数据,别折腾信号,老老实实用管道、socketpair、共享内存加 eventfd 那套。信号做轻量控制面,数据面应该走专门的通道。

5. signalfd:把异步信号变成可读事件,和 epoll 一起工作

5.1 为什么事件循环程序应该用 signalfd

如果你在用 epoll/select 管理大量 fd,传统 handler + 标志位模式很难融入这套模型。你想优雅退出,主循环却阻塞在epoll_wait上,信号来了 handler 把标志位置位了,但epoll_wait不一定立刻返回,除非它被中断。你还要处理 EINTR。这个体验,写过事件驱动服务的人应该都懂。

Linux 专门提供了signalfd,把信号转换成 fd 事件。创建 signalfd 之后,信号不再走异步 handler,而是作为普通数据等你去read。这样 epoll 主循环就能和网络事件、定时器事件统一处理,谁先来就先处理谁,代码结构完全线性化,不用再和异步上下文搏斗。

5.2 signalfd 的读取格式与阻塞信号的关系

signalfd 的使用有个死条件:必须先把对应信号阻塞掉,再创建 signalfd。否则信号在触发 signalfd 之前就会被默认动作处理,进程可能已经退出了。创建方式:

sigset_t mask; sigemptyset(&mask); sigaddset(&mask, SIGINT); sigaddset(&mask, SIGTERM); sigprocmask(SIG_BLOCK, &mask, NULL); int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);

之后read出来的是一组struct signalfd_siginfo,里面常见的字段包括ssi_signo、ssi_pid、ssi_uid、ssi_int或ssi_ptr。注意每次read至少要读sizeof(struct signalfd_siginfo)这么大,内核可能一次给你塞进来多个信号,所以要循环读完再返回 epoll。下面是一个可以和 epoll 配合的最小可运行骨架:

int epfd = epoll_create1(EPOLL_CLOEXEC); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = sfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev); volatile sig_atomic_t running = 1; while (running) { struct epoll_event events[8]; int n = epoll_wait(epfd, events, 8, -1); for (int i = 0; i < n; i++) { if (events[i].data.fd == sfd) { struct signalfd_siginfo si; while (read(sfd, &si, sizeof(si)) > 0) { if (si.ssi_signo == SIGINT || si.ssi_signo == SIGTERM) { running = 0; } } } else { handle_io(events[i].data.fd); } } }

5.3 用 signalfd 的注意点

我在实际项目里用 signalfd 替换掉了不少旧代码里的信号 handler,体验总体很好,但有几个注意点想单独拿出来说。

第一个注意点是SFD_CLOEXEC一定要加。如果程序后续有exec别的二进制,不然 signalfd 这个 fd 会泄漏到子进程里,子进程可能莫名其妙挡住信号。第二个注意点是 signalfd 本身是 Linux 特有,不是 POSIX 接口,跨平台项目要在 Solaris、macOS 上跑的话,还得保留一个自建 socketpair + 异步写字节的方案做兼容。第三个注意点是别把 signalfd 和传统 handler 混用在同一个信号上,否则两套机制同时接收,行为不好预测。我习惯在一开始就决定:这个进程用事件循环模型处理全部信号,统一走 signalfd,绝不在 main 里再注册同一个信号的 sigaction。

6. 排查信号不生效的完整链路

6.1 分清“信号没发”还是“信号没处理”

线上最常见的求助是“我 kill 了进程,它没反应”。这听起来像一句话,但实际原因可能横跨好多个层面。我排查这类问题第一步永远是先分清楚:是发送端根本没发出信号,还是接收进程收到了但没做该做的事?

发送端用kill -0 <pid>只能用来探测进程是否存在,不能验证信号发送成功。要看真正信号发送过程,最简单是用strace附加到发送进程并过滤kill相关系统调用:

strace -e trace=kill,tgkill -p <发送者pid>

接着在另一个终端执行发送动作。如果 strace 里出现了kill(1234, SIGTERM),说明发送端呼叫内核成功。如果没有输出,那是业务代码根本没走到发送逻辑,或者被远程命令执行方式挡住了。这一步能把问题范围砍掉一半。

6.2 检查 /proc 里的信号位图

接收进程收到信号但没执行 handler,常见原因是信号被忽略或被阻塞。Linux 的/proc/<pid>/status会直接暴露这些信息,字段分别是 SigBlk、SigIgn、SigCgt、SigPnd、ShdPnd,后面带的是 64 位十六进制位图。比如 SIGINT 对应第 2 位,也就是1 << (2-1),十六进制0x2;SIGTERM 对应第 15 位,即0x4000。

grep 'Sig' /proc/<pid>/status

看到SigBlk里包含目标信号的位,说明信号被阻塞,正躺在 pending 里没出来;看到SigIgn里包含它,说明代码或启动环境把该信号设成了忽略;SigCgt里没有它,说明进程根本没注册 handler,收到后用默认动作处理,比如 SIGTERM 直接退出,SIGUSR1 默认终止(这个很容易让人误以为“没反应”)。如果SigPnd或ShdPnd里出现了目标信号位,但 handler 迟迟不执行,也基本可以确定是被阻塞了。

还有一种比较隐蔽的情况:SIGCHLD被某些 init 流程设置成了SIG_IGN,导致子进程退出状态收集不到。我们曾在一次容器化迁移后碰到大量僵尸进程,最后就是从SigIgn位看出来的,原来基础镜像在某个角落里把 SIGCHLD 设成了忽略,业务代码再注册 sigaction 也没有覆盖这种行为。

6.3 用 gdb 和 strace 定位 handler 执行现场

如果能确认信号发出了,却无法从业务日志里看到处理逻辑,我会挂到接收进程上,用 gdb 看一眼真正的执行路径。设好catch signal SIGTERM,再触发一次,gdb 会在信号进入进程时停下来,此时bt可以看到它停在哪个线程、哪个函数,是 handler 没被调用还是调用后卡死了。

另一个轻量手段是 strace 附加到接收进程本身:

strace -p <接收者pid>

当信号到达时,strace 通常会打印一行--- SIGTERM ---。如果这一行出现了,说明信号已经投递给该线程,接下来的系统调用就是 handler 里的行为。如果这一行后面卡住不动,多半是 handler 在等锁;如果连这一行都没有,再看/proc的 SigBlk 和 SigIgn,大概率是信号被吞了。

6.4 SA_RESTART 与 EINTR:为什么 read 会突然失败

信号还能制造另一个经典疑难杂症:read、accept、epoll_wait等阻塞调用被信号打断后返回-1,errno为EINTR。很多后台服务原先跑得好好的,加了信号 handler 之后莫名其妙出现“连接读了一半就报错”“accept 返回错误”,往往就是没有处理 EINTR。

sigaction里有一个SA_RESTART标志位,设置后大多数系统调用被信号打断时会自动重新发起,代码层面不用写重试逻辑。但问题在于:并不是所有系统调用都会自动重启,尤其是poll、select、epoll_wait这类等待函数,在 Linux 上即便设置了 SA_RESTART 也可能照常返回 EINTR。所以我的经验是:别把 SA_RESTART 当万能解药,主循环里的阻塞调用务必自己对 EINTR 做循环重试:

do { n = epoll_wait(epfd, events, MAX_EVENTS, timeout); } while (n < 0 && errno == EINTR);

这行代码看起来朴素,但能解决掉相当大一部分“信号一来,服务变栈”的线上事故。还有一个隐蔽点:如果 handler 执行耗时较长,某些系统调用可能被连续打断多次,只重试一次的简单封装依然不够可靠。所以重试循环要用 while 包起来,直到真正返回非 EINTR 为止。

排查信号问题时,我自己始终会过一遍这个清单:发送端是否真的发出来了?接收端是忽略、阻塞、还是根本没捕获?如果捕获了,handler 是否在异步安全范围内?系统调用有没有正确处理 EINTR?这套流程走下来,90% 的信号问题都能在几分钟内定位。剩下的少数疑难杂症,多半要配合内核转储慢慢啃,但只要你前面几步判断准确,就已经比盲目改代码重试高效太多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询