
1. 从一次线上事故说起为什么要啃自定义信号处理三年前我负责的一个数据采集服务在客户现场频繁崩溃日志里只有一行Killed没有任何堆栈。排查了两天才定位到问题进程收到SIGTERM后没有做资源清理正在写入的临时文件被截断重启后读到脏数据直接触发断言。那次之后我把sigaction、信号集、可重入这些概念从头到尾捋了一遍才发现自己以前对信号的理解基本停留在“CtrlC就是SIGINT”的水平。信号Signal是 Unix/Linux 进程间通信里最古老也最容易被低估的机制。它不像管道、共享内存那样有明确的“数据通道”而是以“事件通知”的形式打断进程的正常执行流。默认行为无非是终止、忽略、暂停、继续这几种但真正让信号变得强大的是自定义信号处理——你可以注册自己的处理函数在信号到达时执行特定逻辑比如优雅关闭、重载配置、转储状态、唤醒工作线程。这篇文章面向的是已经写过signal()或sigaction()但踩过坑的后端开发、嵌入式工程师和系统运维人员。我会从信号集的底层结构讲起拆解sigaction的每个字段给出可直接复用的代码模板重点讲清楚异步信号安全这个绝大多数教程一笔带过却最容易出事的点。全文基于 Linux 环境POSIX 标准代码用 C 语言演示但思路对任何调用系统 API 的语言都通用。注意信号处理函数运行在中断上下文能调用的函数极其有限。把printf、malloc、syslog放进处理函数是新手最常见的致命错误后面会专门用一节讲清楚。2. 信号集的本质一块位图但操作有讲究2.1 sigset_t 到底是什么很多人第一次看到sigset_t以为是个结构体数组其实在 glibc 里它就是一个unsigned long数组每个 bit 对应一个信号编号。Linux 有 64 个信号所以 64 位机器上两个unsigned long就够了。你可以把它理解成一张“开关表”某位为 1 表示该信号在集合中。但绝对不要自己去操作这些 bit。不同平台sigset_t的内部布局不一样直接位运算会写出不可移植的代码。标准提供了一组操作函数#include signal.h sigset_t set; sigemptyset(set); // 清空集合 sigfillset(set); // 填满所有信号 sigaddset(set, SIGINT); // 加入 SIGINT sigdelset(set, SIGINT); // 移除 SIGINT sigismember(set, SIGINT); // 判断是否在集合中返回 1/0这几个函数永远返回 0 或 -1不会失败除非传了非法信号编号。实测下来sigfillset之后再sigdelset掉SIGKILL和SIGSTOP是最常见的初始化套路因为这两个信号无法被阻塞、捕获或忽略留着它们没有意义。2.2 阻塞信号集与未决信号集这里有个概念必须分清否则后面sigprocmask和sigpending会看晕。阻塞信号集blocked mask是每个线程独立的属性表示“当前我不想被这些信号打断”。信号到达时如果处于阻塞状态它不会丢失而是变成未决pending状态挂起等解除阻塞后再递送。未决信号集pending set是“已经到达但还没被处理”的信号集合。注意标准信号非实时信号不排队同一个信号在未决状态下再来一次只会合并成一个。实时信号SIGRTMIN到SIGRTMAX才支持排队。sigset_t pending; sigpending(pending); if (sigismember(pending, SIGUSR1)) { // SIGUSR1 已经到达但被阻塞了 }我踩过的一个坑在多线程程序里用sigprocmask改阻塞集结果只对调用线程生效其他线程照收不误。正确做法是在创建线程前就设好或者用pthread_sigmask在每个线程里单独设置。这个细节后面第 4 节会展开。2.3 信号集操作的常见误区第一个误区是认为sigfillset会把SIGKILL也加进去然后就能阻塞它。实际上sigprocmask会默默忽略对SIGKILL和SIGSTOP的阻塞请求不报错但也不生效。所以如果你依赖“阻塞所有信号”来做临界区保护必须显式排除这两个。第二个误区是混淆sigpending和“信号是否被处理”。sigpending只告诉你信号在不在未决队列里不告诉你它会不会被处理。如果该信号被忽略SIG_IGN它根本不会进入未决状态。第三个误区是在信号处理函数里调用sigprocmask。虽然它本身是异步信号安全的但改变阻塞集会影响后续信号的递送顺序容易引入难以复现的时序 bug。我的建议是处理函数里只做最少的标记动作阻塞集的调整放在主循环里做。3. sigaction 逐字段拆解比 signal() 强在哪3.1 为什么放弃 signal()signal()是 ANSI C 标准函数但它的行为在不同 Unix 变体上不一致。最要命的一点是在 System V 语义下信号处理函数执行一次后会自动重置为默认行为你必须重新注册而在 BSD 语义下不会重置。Linux 默认是 BSD 语义但如果你编译时定义了_GNU_SOURCE或者链接了特定库行为可能变化。sigaction()是 POSIX 标准语义明确而且能控制更多细节。生产代码里我一律用sigactionsignal()只在写一次性脚本时图省事用一下。3.2 struct sigaction 的四个关键字段struct sigaction { void (*sa_handler)(int); // 简单处理函数 void (*sa_sigaction)(int, siginfo_t *, void *); // 带信息的处理函数 sigset_t sa_mask; // 处理函数执行期间额外阻塞的信号 int sa_flags; // 行为标志 void (*sa_restorer)(void); // 已废弃不用管 };sa_handler和sa_sigaction是联合体二选一。如果sa_flags里带了SA_SIGINFO就用sa_sigaction它能拿到发送者的 PID、UID 和信号附带的值调试时非常有用。sa_mask是处理函数执行期间额外阻塞的信号集。注意正在处理的这个信号本身会自动被阻塞除非指定SA_NODEFER不需要你手动加。我通常会把sa_mask设成sigfillset再排除SIGKILL/SIGSTOP这样处理函数执行时不会被其他信号打断逻辑更简单。代价是如果处理函数里做了耗时操作其他信号会被延迟所以处理函数必须短。sa_flags里最常用的几个标志作用使用场景SA_RESTART被信号打断的系统调用自动重启大多数情况都该加避免read/write返回EINTRSA_SIGINFO使用三参数处理函数需要知道信号来源时SA_NODEFER处理函数执行时不阻塞当前信号极少用容易递归SA_RESETHAND处理一次后重置为默认模拟 System V 语义SA_NOCLDSTOP子进程暂停时不发SIGCHLD只关心子进程退出时SA_RESTART这个标志我强烈建议默认加上。不加的话accept、read、write、select这些阻塞调用被信号打断后会返回 -1 并置errno为EINTR你得在每个调用点写重试逻辑非常烦。加了之后内核自动重启系统调用代码干净很多。但要注意select和poll即使加了SA_RESTART也可能返回EINTR这两个是例外。3.3 一个可直接复用的注册模板#include signal.h #include string.h #include stdio.h static volatile sig_atomic_t g_reload_flag 0; static volatile sig_atomic_t g_shutdown_flag 0; static void handle_signal(int signo, siginfo_t *info, void *ctx) { (void)ctx; switch (signo) { case SIGHUP: g_reload_flag 1; break; case SIGTERM: case SIGINT: g_shutdown_flag 1; break; default: break; } } int install_handlers(void) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_sigaction handle_signal; sa.sa_flags SA_SIGINFO | SA_RESTART; sigfillset(sa.sa_mask); sigdelset(sa.sa_mask, SIGKILL); sigdelset(sa.sa_mask, SIGSTOP); if (sigaction(SIGHUP, sa, NULL) 0) return -1; if (sigaction(SIGTERM, sa, NULL) 0) return -1; if (sigaction(SIGINT, sa, NULL) 0) return -1; return 0; }这段代码里g_reload_flag和g_shutdown_flag用了volatile sig_atomic_t类型这是标准保证的“读写原子”的类型。处理函数只做赋值主循环轮询这两个标志决定行为。这是最稳妥的模式没有之一。4. 异步信号安全那些不能碰的函数4.1 什么是异步信号安全信号处理函数可能在任意时刻打断主程序包括malloc正在修改堆链表、printf正在操作FILE缓冲区的时候。如果处理函数里再调用malloc或printf就会破坏这些数据结构导致死锁或内存损坏。POSIX 定义了一个异步信号安全函数列表只有这些函数能在处理函数里安全调用。列表很短常用的有write注意不是printfreadopen、close_exit、_Exit不是exitkill、sigqueuesigprocmask、sigactionwaitpid、waitmemcpy、memset部分实现不在列表里的printf、fprintf、malloc、free、syslog、pthread_mutex_lock、exit、strerror、localtime。这些函数内部有锁或全局状态在信号上下文里调用就是埋雷。4.2 正确的日志记录方式处理函数里想记日志怎么办用write直接写文件描述符static void safe_log(const char *msg) { write(STDERR_FILENO, msg, strlen(msg)); }strlen本身是安全的它不修改任何全局状态但为了保险我通常把消息长度作为常量传进去避免任何潜在问题。更复杂的日志需求正确做法是在处理函数里只设置标志主循环检测到标志后再调用syslog或写日志文件。这样日志逻辑运行在正常上下文想怎么调就怎么调。4.3 一个真实的反面案例我见过一个服务在处理函数里调用syslog平时跑得好好的压力测试时偶尔卡死。原因是syslog内部有互斥锁主线程正在写日志持有锁信号到达后处理函数又去抢同一把锁直接死锁。这种 bug 极难复现因为需要信号恰好落在锁持有窗口内。提示如果你不确定某个函数是否安全查man 7 signal-safety。这个手册页列出了完整的异步信号安全函数清单比任何博客都权威。4.4 可重入与线程安全的关系异步信号安全比线程安全更严格。一个函数可以是线程安全的用锁保护共享数据但不是异步信号安全的因为锁本身在信号上下文里可能死锁。反过来异步信号安全的函数一定是线程安全的。errno是个典型例子。它是线程局部的但信号处理函数和主程序共享同一个线程的errno。如果处理函数调用了会修改errno的函数返回后主程序的errno就被污染了。标准做法是在处理函数开头保存errno结尾恢复static void handle_signal(int signo) { int saved_errno errno; /* ... 处理逻辑 ... */ errno saved_errno; }这个细节很多教程不讲但在写库代码时非常重要因为调用者可能正依赖errno判断错误。5. 多线程环境下的信号处理实战5.1 信号递送到哪个线程这是多线程信号处理最核心的问题。进程收到的信号内核会挑选任意一个没有阻塞该信号的线程来递送。注意是“任意”不是“主线程”也不是“第一个线程”。这意味着如果你在多个线程里都注册了处理函数具体哪个线程执行是不确定的。这个行为导致两个常见问题一是处理函数可能在非预期线程执行访问了线程局部数据二是如果所有线程都阻塞了某信号它就会一直未决直到某个线程解除阻塞。5.2 推荐模式专线处理生产环境我用的模式是主线程在创建任何工作线程之前先阻塞所有需要处理的信号然后创建一个专门的信号处理线程用sigwait同步等待信号。#include pthread.h #include signal.h static sigset_t g_sigset; static void *signal_thread(void *arg) { (void)arg; int signo; for (;;) { if (sigwait(g_sigset, signo) ! 0) continue; switch (signo) { case SIGTERM: case SIGINT: /* 通知主线程退出 */ g_shutdown_flag 1; break; case SIGHUP: g_reload_flag 1; break; default: break; } } return NULL; } int setup_signal_thread(void) { sigemptyset(g_sigset); sigaddset(g_sigset, SIGTERM); sigaddset(g_sigset, SIGINT); sigaddset(g_sigset, SIGHUP); /* 主线程先阻塞保证后续创建的线程继承这个掩码 */ if (pthread_sigmask(SIG_BLOCK, g_sigset, NULL) ! 0) return -1; pthread_t tid; if (pthread_create(tid, NULL, signal_thread, NULL) ! 0) return -1; pthread_detach(tid); return 0; }这个模式的好处信号处理逻辑运行在普通线程上下文可以调用任何函数包括syslog、malloc、锁信号递送目标明确不会随机打断工作线程sigwait是同步的不需要担心异步安全问题。关键点是pthread_sigmask必须在创建线程之前调用这样子线程会继承阻塞掩码。如果先创建了线程再阻塞那些已经存在的线程可能已经收到了信号。5.3 信号掩码的继承规则新线程创建时继承创建者的信号掩码。所以只要主线程在pthread_create之前阻塞了目标信号所有子线程默认也阻塞。然后信号处理线程用sigwait主动取信号其他线程完全不感知。未决信号是进程级的不是线程级的。如果信号到达时所有线程都阻塞了它它挂在进程的未决队列上任何一个线程调用sigwait或解除阻塞都能取到。5.4 处理函数与 sigwait 不能混用同一个信号如果既注册了sigaction处理函数又用sigwait等待行为是未定义的。内核可能递送给处理函数也可能唤醒sigwait。所以选一种模式要么全用处理函数要么全用sigwait。多线程程序我强烈推荐sigwait。6. 常见问题排查速查表6.1 信号相关故障排查现象可能原因排查方法进程收到 SIGTERM 不退出处理函数注册失败或被覆盖cat /proc/pid/status看SigCgt位图处理函数只执行一次用了signal()且是 System V 语义改用sigaction系统调用返回 EINTR没加SA_RESTART加标志或在调用点重试多线程下处理函数行为诡异信号递送到了非预期线程改用sigwait专线模式处理函数里死锁调用了非异步信号安全函数查man 7 signal-safety信号丢失标准信号不排队多次到达合并改用实时信号SIGRTMINn阻塞了信号但没生效阻塞的是 SIGKILL/SIGSTOP这两个信号无法阻塞6.2 用 /proc 查看信号状态Linux 的/proc/pid/status里有几个字段对调试信号非常有用SigPnd线程组共享的未决信号位图ShdPnd进程级未决信号SigBlk阻塞信号位图SigIgn忽略信号位图SigCgt捕获信号位图位图是十六进制第 n 位对应信号 n1。比如SigCgt显示0000000000000002二进制第 2 位是 1对应信号 2即SIGINT被捕获了。这个技巧在排查“为什么我的处理函数没被调用”时特别管用。6.3 几个我踩过的坑第一个坑在容器里 PID 1 的进程默认忽略SIGTERM。如果你把服务直接作为容器入口docker stop发的SIGTERM会被忽略等超时后SIGKILL强杀导致优雅关闭逻辑根本不执行。解决办法是用exec形式启动或者显式注册SIGTERM处理函数。第二个坑SIGCHLD处理函数里调用waitpid时用了WNOHANG但没循环。如果多个子进程同时退出SIGCHLD可能只递送一次标准信号不排队一次waitpid只能回收一个剩下的变成僵尸。正确做法是循环waitpid直到返回 0 或 -1。第三个坑SIGPIPE默认终止进程。往已关闭的 socket 写数据时内核先发SIGPIPE默认行为是杀进程。网络服务必须忽略它SIG_IGN然后通过write返回的EPIPE错误来处理。第四个坑SA_RESTART对select/poll/epoll_wait无效。这些函数被信号打断后一定返回EINTR必须手动处理。我通常把超时时间设短一点在循环里检查退出标志。7. 写在最后信号处理这块内容文档看一遍觉得懂了真写起来处处是坑。我的经验是能用 sigwait 就别用处理函数能用标志位就别在处理函数里干活能加 SA_RESTART 就别省。这三条原则能避开九成以上的信号相关 bug。另外每次改动信号相关代码后一定要用strace -e tracesignal跑一遍看看信号的实际递送和处理顺序是否符合预期。这个工具比任何日志都直观能看到内核层面的信号收发细节。我现在的习惯是任何涉及信号的新模块先用 strace 验证行为再写单元测试最后才集成到主流程。多花这半小时能省下线上排查两天的功夫。