
写Linux下服务端程序这些年我发现自己跟“信号”signal打交道的次数比跟任何高级框架都多。最典型的一次线上事故后端进程在凌晨被OOM Killer杀掉可我们在日志里死活找不到崩溃点后来排查才发现进程压根没处理SIGTERM系统给足了它优雅退出的机会它却连个善后动作都没做。从那以后我把Linux系统编程里的信号机制从头捋了一遍把踩过的坑、看过的内核行为、日常调试手段都沉淀成了一套笔记。这篇内容适合正在写服务端、嵌入式、或者任何需要跟进程生命周期打交道的Linux C/C开发者。不绕弯子直接从信号机制的本质讲起再到处理函数怎么装才安全、信号怎么发才不丢、多线程下信号归谁管、线上怎么排查信号问题。全文围绕我在实际项目中亲身验证过的结论来写你照着做基本能避开绝大多数信号相关的雷。1. 信号的底层直觉它更像“回合制游戏里的邮件通知”1.1 信号的三条来源通道很多教材喜欢把信号叫做“软件中断”这个词容易误导人。它并不真的像硬件中断那样能在任何指令边界立刻跳转执行你的处理函数。信号在Linux里的投递时机更像是一个固定回合的邮件通知内核把事件放进进程的“信箱”等进程从内核态返回用户态的那一刻才被统一检查并派发。信号的来源基本就三类。第一类是硬件异常比如除零、访问非法内存CPU在执行指令时触发了异常内核会把这个异常包装成SIGFPE、SIGSEGV之类再投递给你。第二类是终端按键你在终端按CtrlC、Ctrl\、CtrlZ分别产生SIGINT、SIGQUIT、SIGTSTP发给前台进程组。第三类就是另一个进程或者当前进程自己通过kill、sigqueue、raise等系统调用主动向目标进程发送一个信号。理解这一点你就明白了为什么一个信号处理函数可以在“任意时刻”被调用——因为主程序执行到任意一条普通指令时都可能碰到一次用户态/内核态切换然后就被信号打断了。这种异步插入特性是后面所有信号编程坑的根源。1.2 默认动作没装处理函数时信号怎么处置进程每个信号都有一套默认行为。你什么都不管的时候内核按信号编号对应的默认动作处理进程大体分五类终止进程Term、终止并生成core文件Core、暂停进程Stop、恢复运行Cont、忽略Ign。信号编号默认动作常见触发场景SIGHUP1Term终端挂断或会话leader退出SIGINT2TermCtrlCSIGQUIT3CoreCtrl\SIGILL4Core非法指令SIGABRT6Coreabort()SIGFPE8Core除零等算术异常SIGKILL9Term强制杀死进程SIGUSR110Term用户自定义用途SIGSEGV11Core非法内存访问SIGUSR212Term用户自定义用途SIGPIPE13Term写向已关闭的管道/SocketSIGALRM14Termalarm()定时到期SIGTERM15Termkill默认发出的“请退出”信号SIGCHLD17Ign子进程停止或退出SIGCONT18Cont继续执行被暂停的进程SIGSTOP19Stop暂停进程SIGTSTP20StopCtrlZ这里有个特别重要的边界SIGKILL和SIGSTOP是两个“特权信号”它们不允许被捕获、阻塞或忽略。你可以理解为内核的最后手段——SIGKILL用来物理消灭进程SIGSTOP用来强制暂停进程。程序里装任何handler都没用这也是为什么有些服务即使挂着清理逻辑一旦被kill -9也留不下任何善后痕迹。提示kill -9能解决一切普通信号解决不了的问题但它同样不给你任何机会。生产环境的主进程最优先要装的handler其实是SIGTERM因为它是kill命令的默认信号代表“系统请你体面退出”。2. 安装处理函数为什么我彻底弃用了signal()2.1 signal()的历史遗留同样一个函数两套行为第一次写信号处理时大家肯定都是图省事直接用signal(SIGINT, handler)。问题来了POSIX标准并没有固定signal()的语义历史上存在System V和BSD两套实现两者差异非常大。System V的signal()处理函数执行完之后这个信号的处置方式会被重置为默认值。这就有个竞态窗口假设handler正在跑此时又来了第二个SIGINT因为信号处理方式已被重置默认行为是终止进程你的handler还没跑完进程就死了。BSD的signal()处理函数执行完不会重置而且被信号打断的慢速系统调用会自动重启。glibc在Linux上实现了BSD语义看起来似乎够用。但问题在于signal()无法设置更多属性比如无法精确控制handler执行期间的屏蔽信号集合也无法决定系统调用是否要自动重启。你依赖它的行为本质上依赖glibc的实现细节而不是POSIX承诺。我刚入行时就被System V语义坑过一回后来把项目里所有signal()调用全换成了sigaction()再没出过同类问题。现在的建议非常明确新代码一律使用sigaction()signal()只配出现在教科书里。2.2 sigaction()参数逐项拆解sigaction()长这样#include signal.h int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);其中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_mask在handler执行期间额外屏蔽的信号集合。注意当前正在处理的信号默认会自动屏蔽这是内核帮你加的除非你显式设置了SA_NODEFER。sa_flags常用标志位有SA_RESTART被打断的系统调用自动重启、SA_SIGINFO改用sa_sigaction接收更多上下文、SA_NOCLDWAITSIGCHLD不产生僵尸进程等。一个实际的“按3次CtrlC才退出”的demo#include stdio.h #include signal.h #include string.h #include stdlib.h #include unistd.h static volatile sig_atomic_t count 0; static void handler(int sig) { (void)sig; count; // 只更新标志不做别的 } int main(void) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; if (sigaction(SIGINT, sa, NULL) 0) { perror(sigaction); exit(1); } while (count 3) pause(); // 一直挂起等信号 printf(received 3 SIGINT, exiting\n); return 0; }实测运行不按CtrlC之前进程一直挂着按够3次正常退出。SA_RESTART让pause()的行为更可预期同时避免慢速系统调用被打断后返回EINTR。2.3 处理函数执行完就“恢复默认”最隐蔽的坑在没有SA_RESTART的情况下如果进程正阻塞在read()等慢速系统调用上收到信号后read()会返回-1并设置errno为EINTR。很多新手以为信号处理完就自动接着读了结果程序莫名退出或者少读一段数据。char buf[64]; ssize_t n read(STDIN_FILENO, buf, sizeof(buf)); if (n 0) { // 如果 errno EINTR说明是被信号打断的 if (errno EINTR) printf(read interrupted by signal\n); }解决办法有两个层面。第一个层面是在sigaction里加SA_RESTART让常见系统调用自动重启第二个层面是业务代码本身要对EINTR做循环重试因为有些系统调用即使设置了SA_RESTART也依然可能返回EINTR比如poll()。我见过不少线上服务主循环里poll()被信号打断后没有重试结果进程空转业务量暴跌。记住一条原则处理信号不是只装好handler就完了还要处理系统调用被打断的后果。3. 给进程“递条子”kill、raise与带数据的sigqueue3.1 kill函数的进程组语义和权限边界kill虽然名字杀气腾腾但实际作用是“向进程或进程组发送信号”并不代表杀死。int kill(pid_t pid, int sig);pid参数的几种取值坑很多pid 0发送给指定进程。pid 0发送给与当前调用者同进程组的所有进程。pid -1发送给调用者有权限发送的所有进程非常危险脚本里误用可能波及一堆服务。pid -1发送给进程组ID等于-pid的所有进程。权限检查的内核逻辑发送者的real或effective UID必须等于目标进程的UID或者拥有CAP_KILL权限。另外向PID 1init发送SIGKILL这类“终极信号”现代Linux也会有特殊保护不是有root就一定能杀掉。这段代码演示了父进程用kill()通知子进程#include stdio.h #include signal.h #include unistd.h #include sys/wait.h int main(void) { pid_t pid fork(); if (pid 0) { /* 子进程 */ for (;;) pause(); } /* 父进程 */ sleep(1); kill(pid, SIGTERM); /* 给子进程发“请退出” */ wait(NULL); return 0; }3.2 raise发信号给自己测试handler的最佳搭档raise(sig)等价于kill(getpid(), sig)但更简洁。它经常被用在测试场景里让进程在可预测的位置触发一次信号验证handler是否符合预期。还有一点容易忽略signal(SIGINT, SIG_IGN)表示忽略信号而不是捕获信号。被忽略的信号事件根本不会递送给handler相当于你压根不想管它。用SIG_IGN忽略SIGPIPE是服务端最常见的应对手段后面第7章会再详细说。3.3 sigqueue标准信号丢包实时信号才带得动“私货”普通kill()只发一个信号编号带不了数据。如果业务上希望信号本身捎带一个整数或指针就得用sigqueue()。int sigqueue(pid_t pid, int sig, const union sigval value);union sigval可以装一个整数或指针。但注意sigqueue()只对实时信号有效也就是信号编号要在SIGRTMIN和SIGRTMAX之间。标准信号比如SIGUSR1用sigqueue()传数据行为是未定义的且由于标准信号不排队发多次也可能合并成一次。这里是一个父进程给子进程通过实时信号传整数的完整例子#include stdio.h #include signal.h #include unistd.h #include string.h #include stdlib.h #include sys/wait.h static void rt_handler(int sig, siginfo_t *si, void *uctx) { (void)sig; (void)uctx; printf(child received value: %d\n, si-si_value.sival_int); } int main(void) { pid_t pid fork(); if (pid 0) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_sigaction rt_handler; sa.sa_flags SA_SIGINFO | SA_RESTART; sigemptyset(sa.sa_mask); sigaction(SIGRTMIN 1, sa, NULL); for (;;) pause(); } /* 父进程 */ sleep(1); union sigval sv; sv.sival_int 42; sigqueue(pid, SIGRTMIN 1, sv); sleep(1); kill(pid, SIGTERM); wait(NULL); return 0; }实际跑一下子进程能打印出“received value: 42”。这个机制适合做轻量级的状态同步比如通知工作线程“队列积压数已经达到阈值这个阈值作为参数传过去”。4. 阻塞、未决与排队标准信号“容易丢”的真相4.1 sigprocmask为什么业务逻辑需要暂时屏蔽信号有时候进程正处在某个不允许被插队的关键区段比如正在更新一段复杂的内存结构、正在写数据库事务日志如果突然冒出一个SIGTERM handler去访问同一批资源后果不堪设想。这种场景需要把信号先“压住”等关键区段结束再放行。用sigprocmask()sigset_t newset, oldset; sigemptyset(newset); sigaddset(newset, SIGINT); sigaddset(newset, SIGTERM); sigprocmask(SIG_BLOCK, newset, oldset); /* 这里处于关键区段SIGINT/SIGTERM只会变成未决不会打断 */ sigprocmask(SIG_UNBLOCK, newset, NULL);三个操作方式SIG_BLOCK往当前屏蔽集里追加、SIG_UNBLOCK移除、SIG_SETMASK设置为指定集合。记住阻塞不是忽略。忽略是信号来了直接扔进垃圾桶阻塞只是把信号扣在邮箱里等你解除阻塞再投递。4.2 一个可复现实验标准信号真的会合并下面这段代码是验证“标准信号不排队”最直观的方法。#include stdio.h #include signal.h #include unistd.h #include string.h #include stdlib.h static void handler(int sig) { (void)sig; write(STDOUT_FILENO, got it\n, 7); /* 异步安全 */ } int main(void) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler handler; sa.sa_flags SA_RESTART; sigemptyset(sa.sa_mask); sigaction(SIGINT, sa, NULL); sigset_t set, oldset; sigemptyset(set); sigaddset(set, SIGINT); sigprocmask(SIG_BLOCK, set, oldset); printf(please press CtrlC five times...\n); sleep(5); sigset_t pending; sigpending(pending); if (sigismember(pending, SIGINT)) printf(SIGINT is pending\n); sigprocmask(SIG_UNBLOCK, set, NULL); sleep(1); return 0; }实验过程程序先把SIGINT阻塞你疯狂按CtrlC然后解除阻塞。结果会让你意外——handler只执行了一次不是五次。原理很简单未决信号在内核里是用一个位图表示的每个标准信号只占一个bit。只要这个信号已经“挂号”了后面再来的同款信号就直接丢弃直到它被处理掉。4.3 实时信号如何做到排队保序实时信号之所以“实时”体现在三件事每个信号都有独立的排队槽位、允许携带额外数据、多个同类型信号不会被合并。这样就能保证你发10次SIGRTMIN1处理函数就老老实实被调用10次而且按照发送顺序来。特性标准信号实时信号信号编号范围1~31传统SIGRTMIN~SIGRTMAX通常34~64多次发送是否合并会合并只记一次不会合并逐个排队是否携带额外数据不带可用sigqueue携带整数或指针递送顺序不保证同类型按发送顺序队列容量有限受RLIMIT_SIGPENDING限制实际操作中SIGRTMIN在glibc里可能是个动态值不要硬编码数字始终用SIGRTMIN n来引用自定义实时信号。另外排队信号也会受RLIMIT_SIGPENDING限制如果队列溢出发送方会收到EAGAIN代码里不能忽略这个返回值。5. 处理函数里的“安全区”为什么不能随便调用printf5.1 printf和malloc在信号处理函数里引发的死锁这可能是信号编程里最阴间的问题。表面上看你只是想在收到信号时打印一行日志于是写了static void handler(int sig) { printf(got signal %d\n, sig); }灾难往往发生在不经意间。假设主程序正在调用printf()它已经拿到了stdout内部的锁然后打印到一半信号来了处理器去跑你的handlerhandler里又调printf()再次去拿同一把stdout锁——死锁。malloc()同理。主程序可能在malloc()内部操作堆管理链表时持有堆锁信号处理函数里再malloc()就卡死。我见过不止一个服务因为这种写法表现为“收到信号后进程不退出也不响应CPU却是0%”最后用gdb一挂上去栈全在锁等待上。5.2 黄金法则handler只置标志、写管道不干重活POSIX标准维护了一张async-signal-safe函数清单里面只有sig_atomic_t级别的操作、read、write、open、close、_exit等极少数函数是保证安全的。而printf、sprintf、malloc、free、syslog、pthread_*之类全都不在清单里。所以我的做法通常是这样handler里只更新一个volatile sig_atomic_t标志位然后通过一个预先打开的pipe向主循环发一个字节通知。主循环用poll或epoll监听这个pipe收到通知后再统一处理。static int notify_fd -1; static void handler(int sig) { (void)sig; unsigned char ch 1; ssize_t ignored write(notify_fd, ch, 1); (void)ignored; }这种设计把信号处理里的“异步爆炸”转变成了主循环的“同步轮询”无论是加锁、分配内存、写数据库放主循环里干都好办得多。如果你实在需要一个可靠的日志记录请用write往已打开的fd写不要碰printf族函数。5.3 volatile sig_atomic_t与errno两个更小的细节第一个小细节共享标志位必须用volatile sig_atomic_t。普通变量会被编译器优化到寄存器里主循环不断读可能永远读不到handler更新的值。带上volatile让每次读都落到内存。sig_atomic_t在x86上就是int读写是原子的但它只能保证“单个读或写是原子的”不保证自增操作原子比如count就不是安全的。第二个小细节errno是线程局部存储handler里的任何安全函数都可能修改errno导致处理返回后主程序的错误判断被污染。正确的做法是进入handler先保存errno退出前恢复static void handler(int sig) { (void)sig; int saved_errno errno; /* ... 中间只做安全操作 ... */ errno saved_errno; }这个小习惯在调试边缘性bug时能省很多时间。6. 多线程与信号信号到底发给哪个线程6.1 线程组信号投递规则信号编程进入多线程时代后最让人困惑的就是“kill(pid, sig)到底发给谁”。答案是kill()产生的是进程级信号内核会在这个进程的所有线程里挑一个没有阻塞该信号的线程来投递。如果所有线程都阻塞了信号就留在进程级的未决集合里。而pthread_kill(thread, sig)是线程级信号只投递给指定线程。此外同步信号比如SIGSEGV、SIGFPE这种由CPU异常产生的总是投递给触发异常的线程。这就意味着SIGSEGV的handler可能跑在任意线程里而且就是肇事线程这点对多线程崩溃时保存现场信息非常重要。6.2 把所有信号逻辑挪进专用线程sigwait方案既然handler会在任意线程异步执行而多线程程序里资源往往有线程归属那最稳的思路就是别让信号handler在随机线程里跑而是用一个专门的信号处理线程来同步等待信号。做法分三步创建任何线程之前先pthread_sigmask(SIG_BLOCK, set, NULL)把要处理的信号全部阻塞住。子线程会继承这个屏蔽集。创建一个专用线程在循环里调用sigwait()或sigwaitinfo()同步接收信号。收到的信号按普通业务逻辑处理不再受异步安全性限制。#include stdio.h #include signal.h #include pthread.h static sigset_t sig_set; static void signal_worker(void) { int sig; for (;;) { if (sigwait(sig_set, sig) ! 0) continue; printf(thread %lu handled signal %d\n, (unsigned long)pthread_self(), sig); if (sig SIGTERM) break; } } int main(void) { sigemptyset(sig_set); sigaddset(sig_set, SIGTERM); sigaddset(sig_set, SIGINT); pthread_sigmask(SIG_BLOCK, sig_set, NULL); pthread_t tid; pthread_create(tid, NULL, (void *(*)(void *))signal_worker, NULL); pthread_join(tid, NULL); return 0; }用sigwait()处理信号最大的好处是你可以在正常线程上下文里尽情使用锁、malloc、日志库不用担心异步信号安全性。它把“异步通知”转化成了“同步等待”心智负担直线下降。6.3 Linux特有的signalfd把信号变成文件描述符Linux还提供了一个更契合事件循环的接口signalfd()可以把信号集合绑定到一个文件描述符上。进程先用sigprocmask阻塞信号再用read()从这个fd读取信号事件或者把它丢进epoll让事件循环统一管理。#include sys/signalfd.h int sfd signalfd(-1, sig_set, SFD_NONBLOCK); /* 然后 sfd 交给 epoll 监听 */这种方式对用epoll写高并发服务的人特别友好。信号来了就和网络事件一样走统一的事件分发路径不会再出现“某线程莫名其妙被信号打死”的诡异局面。不过要清楚signalfd是Linux特有接口不是POSIX跨平台项目需要做好隔离层。7. 调试信号问题的三板斧与线上信号故障复盘7.1 strace看进程到底收到了什么信号排查线上问题时第一步永远是确认进程收到了什么信号、从哪发来的。strace可以挂在进程上看信号strace -p pid -e tracesignal输出里会看到类似--- SIGTERM {si_signoSIGTERM, si_codeSI_USER, si_pid12345} ---的片段。si_pid告诉你这个信号是谁发的si_code是SI_USER说明来自用户态kill调用是SI_KERNEL则多半来自内核或硬件异常。如果排查的是“进程突然退出但没有core”先用dmesg | tail看有没有OOM Killer的记录再用strace确认最后收到的信号。7.2 gdb handle让信号成为断点而不是屠刀调试信号处理逻辑时你往往希望信号到达的瞬间让程序停下来而不是触发handler直接继续。进入gdb后用handle命令控制信号(gdb) handle SIGTERM stop print nopass (gdb) continuestop表示收到SIGTERM时gdb停住nopass表示停在信号处理之前、不把信号交给程序。这样你就有机会在信号处理的现场做bt、看寄存器、检查变量。如果是SIGSEGV也可以先handle SIGSEGV stop nopass然后再continue崩溃瞬间停在肇事指令位置能直接定位是空指针还是越界。用这套方法我处理过好几次“偶尔段错误但复现不了”的问题。信号不是玄学只是需要正确的抓取时机。7.3 /proc/PID/status看一眼进程的信号屏蔽状态很多时候不用上gdb直接读/proc/pid/status就知道进程对信号的处置情况SigPnd: 0000000000000000 ShdPnd: 0000000000000000 SigBlk: 0000000000000000 SigIgn: 0000000000001000 SigCgt: 0000000000000002每一行都是一个64位的十六进制掩码其中最低位是信号1。比如SigIgn值是0x1000换算二进制第13位为1也就是信号13SIGPIPE被忽略了。SigCgt为0x0002表示信号2SIGINT被安装了handler。我自己常用一个小Python脚本换算def decode(hex_str): val int(hex_str, 16) sigs [i for i in range(1, 65) if val (1 (i - 1))] return sigs查SigBlk能确认进程有没有在某个环节悄悄后台阻塞了信号查SigCgt能确认handler是否真的装上了。有一次同事说“我明明装了SIGTERM handler”一查SigCgt是0原来装完马上又被某初始化库重置了。7.4 三种高频线上故障的根因与对策故障一进程变成僵尸。子进程退出后父进程没有调用wait()回收并且父进程没有处理SIGCHLD。对策是安装SIGCHLD的handler在handler里面用waitpid(-1, NULL, WNOHANG)循环收割所有已退出的子进程如果业务上不要子进程可以直接signal(SIGCHLD, SIG_IGN)自动回收。故障二进程“静默死亡”或突然无输出。很多情况下是SIGPIPE惹的祸——对端关闭了连接或管道你这边继续write()内核直接给进程发SIGPIPE默认动作为终止进程。对策网络服务里signal(SIGPIPE, SIG_IGN)然后让send()返回EPIPE错误码使用send()时可以加MSG_NOSIGNAL标志或者自己处理EPIPE。故障三SIGSEGV崩溃但没有core文件。先看ulimit -c是否为unlimited然后确认/proc/sys/kernel/core_pattern路径可写。很多容器环境下默认禁止写core需要专门挂载卷或者改用gdb对外采集。生产的崩溃现场很有价值一定提前把core链路验证好不要等到事故发生时再研究。我在实际项目里踩过太多信号的坑总结下来核心思路就一条能把信号转成普通的事件循环事件就不要依赖在随机线程里异步执行的handlerhandler里只允许做最轻量的事其他全推给主流程。按这个原则写信号处理代码绝大多数诡异问题都能从源头上规避。