
上篇我们聊完了 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); } } }这个模型在很多守护进程里都能看到。任务入队的人负责向队列加数据并发送一个 SIGUSR1worker 线程通过 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); }单独看逻辑没有任何问题可它随时可能把整个进程搞崩。原因是信号处理函数会在进程执行到任意一条指令时被插入这个位置是不确定的。如果你恰好正在调用printfprintf内部已经申请了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不只是发一个编号实时信号的价值在收发双方配合时最能体现。发送方用sigqueueunion 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 tracekill,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)十六进制0x2SIGTERM 对应第 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等阻塞调用被信号打断后返回-1errno为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% 的信号问题都能在几分钟内定位。剩下的少数疑难杂症多半要配合内核转储慢慢啃但只要你前面几步判断准确就已经比盲目改代码重试高效太多了。