首页 / 资讯中心 / 文章详情

UNIX信号机制全解:从内核原理到高并发工程实践

UNIX信号机制全解:从内核原理到高并发工程实践 ★ FEATURED ARTICLE
来电了但程序死了——十有八九是因为你没看懂UNIX信号。在UNIX/Linux下搞后台开发、网络编程或者嵌入式系统几乎没有绕开信号的。进程间要通知、要处理异常终止、定时器到点该干活、被用户按了CtrlC底层全靠信号这套机制在背后广播。你写守护进程重启逻辑要遇到它写网络服务优雅关闭要遇到它排查诡异的进程无响应但没退出还是要遇到它。《UNIX高级环境编程》第十章把这块讲得很细但书里不少内容偏原理缺一个从实际工程角度的串联。这篇笔记就按我自己的理解把信号机制从头到尾捋一遍从内核怎么产生和递送信号到用户态怎么拦截处理再到高并发程序里那些让你头疼的坑尽量用大白话讲明白后面直接能给项目用。信号这套东西说白了就是一个简单的异步事件通知模型核心机制不出24个函数但每个函数背后都藏着老UNIX系统三十多年的设计取舍。想真正看懂man手册里那些术语先理解三件事信号是怎么产生的它在进程里是排队还是丢失以及处理函数跑起来之后你的程序到底处于一个什么状态。这三件事通了其余的不过是API细节。1. 信号机制的核心思路与设计哲学1.1 信号到底是什么一场内核发起的异步电话你可以把信号理解成内核给进程打的电话某个事件发生了内核主动通知进程该来处理一下。这个通知是完全异步的进程在哪儿执行、在做什么一概不管。可能正执行到main函数第50行也可能正卡在read()系统调用里等磁盘IO信号来了它会被打断先跳去处理信号处理完再回来接着干。信号和异常、中断的区别要分清中断是硬件级别的比如网卡收到数据触发IRQCPU通过中断向量表自动跳转打断的是整个处理器正在跑的活异常是同步的比如除零、缺页是当前指令自己引发的信号则是异步的不知道哪一刻会来而且它不只来自硬件——一个普通用户态进程用kill()函数就能给另一个进程发信号。这部分在第十章开篇就讲了但很多人读完没意识到一个关键点信号是在内核态与用户态之间切换时被检查递送的。当一个进程从内核态返回用户态时内核会先翻一下该进程的信号队列看有没有该递送的信号。有就直接把用户态的指令流拐到一个信号处理函数上去处理完再恢复原样。这个返程查信号的机制决定了信号的递送时机不是事件发生立即递送而是等当前系统调用返回、或者进程被调度到CPU上从内核态切回用户态时挑一个合适的时机再递送。这也是为什么长时间跑在用户态计算密集的进程信号看起来迟到了——它可能确实是在内核态检查时才被处理。1.2 信号是稀缺资源数量有限语义固定早期的ATT UNIX信号数量极少SIGUSR1、SIGUSR2这种是专门留给应用程序自定义用的至今也是这个习惯。Linux上的标准信号编号从1到31每个信号都有默认语义多数进程如果不显式接管内核会用默认动作处理终止进程、终止并产生core dump、忽略、停止或继续进程。经典的处理动作可以看下表信号编号(Linux)默认动作触发场景SIGHUP1终止进程终端断开、守护进程重读配置SIGINT2终止进程CtrlCSIGQUIT3终止coreCtrl\SIGKILL9终止不可捕获强制杀进程SIGSEGV11终止core非法内存访问SIGPIPE13终止进程写无读端的管道SIGALRM14终止进程alarm()定时器到期SIGTERM15终止进程kill命令默认信号SIGCHLD17忽略子进程停止或终止SIGUSR1/210/12终止进程用户自定义SIGSTOP19停止进程暂停执行SIGCONT18继续执行恢复被暂停的进程这些语义不能随便改。比如你绝不能捕获SIGKILL或SIGSTOP这是内核最后的保底手段——如果一个进程可以拦截所有信号那它就能拒绝被关闭系统管理员就束手无策了。第十章讲signal()函数的限制时特意强调这一点实际开发中也见过新手试图signal(SIGKILL, handler)结果发现完全无效这是内核硬编码的保留。1.3 为什么信号机制经历了不可靠到可靠的演进早期UNIX信号实现有几个臭名昭著的毛病处理完一个信号后信号处理器会被重置为默认动作信号在递送期间如果又被同类型信号打到可能会丢失多个相同信号密集到来时并不排队而是合并且为一个。这就造成了著名的窗口期问题——在进入信号处理函数后、重新注册处理函数前若信号再来一次进程就被默认动作干掉或丢信号。signal()这个老接口就是那种实现。它第一个参数是信号编号第二个是处理函数而且注册后在处理函数执行前会自动恢复默认行为不重新注册你就等死吧。很多人学UNIX编程时背过这个函数以为信号就是这么用的其实从POSIX起正规做法是用sigaction()它允许你设置标志位SA_RESETHAND就是不重置SA_NODEFER是不阻塞同类信号才能真正控制这些细节。一句话总结可靠与不可靠的差别可靠信号在执行期间不会被重置为默认也不会因为同信号重入而丢失.当然即使可靠信号内核也不保证信号排队。除了实时信号后面展开标准信号在未决状态下再次产生相同信号合并处理后只递送一次。看书时这一节容易漏掉但它是理解后面sigprocmask和未决信号集合的基础。2. 信号的产生、注册与递送一个信号从出生到死亡的完整过程2.1 信号的出处硬件、软件、用户三条路互联信号的产生无非三个来源硬件异常CPU执行指令时产生的异常由内核转化为信号。最常见的SIGSEGV段错误就是访问了没有权限的内存地址SIGFPE除零浮点异常SIGBUS总线错误不对齐访问。软件条件进程自身或内核主动发出的。比如alarm()定时器到期产生SIGALRM子进程退出触发SIGCHLD对端关闭导致写管道/套接字时收到SIGPIPE。用户主动操作终端上按CtrlC发送SIGINT或者另一个进程调用kill()、killpg()精确指定目标。有意思的是硬件异常转信号的过程并不总是立即的比如x86上SIGFPE发生在浮点单元里有时要等下一次浮点操作才真正抛出这就导致某些浮点异常的定位变得玄学。写底层引擎时最好把feenableexcept()这类浮点异常开关也打开让异常尽早在指令级蹦出来不然排错排到怀疑人生。2.2 未决、屏蔽与递送进程看待信号的三重状态机一个信号从产生到处理完内部其实走了三个状态产生generation内核为该进程记录一个未决信号pending。每个进程有一个位图第n位表示第n号信号是否未决。未决pending信号已经产生但还没有被进程处理。可能是被屏蔽blocked也可能单纯是要等进程从内核态返回用户态。递送delivery进程安排执行信号处理函数或执行默认动作。屏蔽是这里最核心的概念。通过sigprocmask()把某些信号加进屏蔽字signal mask就告诉内核这类信号先别递送给我放着。但注意屏蔽不是丢弃只是延后。屏蔽期间信号一直停留在未决集合里等你解除屏蔽后立即补送。这就像电话留言手机静音时不接但语音信箱里都记着一打开就一起弹出来提醒你。有一个细节值得反复品未决信号是位图队列的组合。对于标准信号即使屏蔽期间来了10次SIGCHLD未决位图就那一位解除屏蔽后只递送1次对于实时信号SIGRTMIN~SIGRTMAX编号34~64内核会为每个实时信号维护独立的sigqueue队列可以排队且带伴随数据union sigval递送顺序按编号从小到大、同号按发送顺序。这就是可靠信号与实时信号在术语上的含义差异可靠标准信号不丢失但也不排队实时信号可以排队并携带数据。2.3 递送时机系统调用返回路径上的检查点看到很多人困惑为什么我的信号处理程序有时候响应得很慢其实信号递送不是即时的内核在几条固定的返回路径上检查未决信号系统调用正常返回时系统调用因信号而中断返回时EINTR后面详谈进程刚被schedule调度上、从内核态返回用户态时这意味着一个进程如果在用户态做纯计算且不被调度器打断信号会悬着直到贴到时间片用光、进程进入内核态或主动调用sigsuspend()、pause()这类系统调用。所以信号处理要做实时响应程序本身也得配合不能长时间闷头跑CPU而完全不进内核。此外中断系统调用这块儿是UNIX程序设计里一个极其经典的坑。如果进程正阻塞在read()上等网络数据信号来了处理完成后系统调用会直接返回失败errno设置为EINTR。如果不检查EINTR直接当错误处理程序就会莫名退出——大多数网络服务崩溃其实不是逻辑错而是没处理EINTR。第十章反复强调要在所有阻塞系统调用后检查errno EINTR并决定是重试比如read可以循环重调还是让路比如交给runloop。3. 核心API与信号处理实操要点3.1 signal()与sigaction()一个不该再用的老接口和真正要掌握的接口signal()是初学UNIX时最早上手的一个函数但在工程里它基本是反面教材。原因也不复杂就是前面说的窗口期问题——信号处理期间如果再次到来默认动作会直接杀掉进程。老程序员用它的习惯是每次在handler里第一行重新signal()注册自己但这里有个致命竞态从信号被内核递送到handler执行到第一行重新注册之间仍然有个极短的窗口信号再来的话还是会触发默认动作。真正严谨的写法是用sigaction()它的C结构体让你明确指定三件事新的行为、旧的保存行为、使能的附加标志。原型如下#include signal.h int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);其中struct sigaction里有3个关键字段sa_handler指向处理函数可以填SIG_IGN忽略、SIG_DFL恢复默认或自定义函数名。sa_mask一个sigset_t位图指定在处理该信号期间额外屏蔽哪些信号。sa_flags行为标志常用SA_RESTART让被中断的系统调用自动重试SA_NOCLDSTOP只关注子进程终止而非停止SA_SIGINFO启用带附加信息的处理方式sa_sigaction。用SA_RESTART后read、write这类慢系统调用被信号打断时内核会自动重启动而不是返回EINTR这样就大大减少了必须手动处理EINTR的上层代码。但SA_RESTART不是万能的如果你调用的是select()、poll()、epoll_wait()这种本身就设计成可中断的接口man手册里明确说不受SA_RESTART影响即使设置了SA_RESTART它们依然返回EINTR此时必须显式判断。这也是为什么很多事件循环框架的代码里对epoll_wait的返回值总要包一层if -1 errnoEINTR continue的原因。3.2 kill、raise、alarm与pause进程自己发信号kill()函数可以给同用户权限下任意进程发信号签名是int kill(pid_t pid, int sig)。pid的语义是重点pid 0发给指定PID的进程pid 0发给同进程组的所有进程pid 0发给进程组ID为abs(pid)的所有进程pid -1发给所有有权限发送的进程不包括init和自身所在的内核线程这四种分发规则在网络服务里非常实用。比如带看护进程的服务框架父进程shell脚本要优雅关停所有子进程时用kill(-pgid, SIGTERM)一条命令把整组都通知了比遍历pid一个个kill要稳得多。raise()则是发给自己的等价于kill(getpid(), sig)常用于进程内部主动触发某个信号处理逻辑。锁死时想保留现场也可以用raise(SIGABRT)主动生成core dump。很多水平不定的服务在检测到内部状态机异常时会主动abort而不是exit就是为了留core文件事后分析。alarm(unsigned int seconds)设置一个绝对定时器倒计时到0时向进程发送SIGALRM。注意它是粗粒度的——精度只有秒级而且每一个进程同一时刻只能有一个alarm生效后调用的会覆盖前一个。要用高精度定时还是得靠timer_create配合SIGEV_SIGNAL。高风险场景是alarm加阻塞IO做超时控制的老套路先alarm(n)再读IO最后alarm(0)取消。这套路里如果alarm没取消进程就挂了信号处理里再做一些危险操作极易出问题。pause()则很简单——让调用进程挂起直到捕获到一个信号。配合alarm可以写出一个最简单的sleep实现配合sigsuspend()可以在原子性地修改屏蔽字与挂起之间选择好信号是等待特定信号的标准姿势。3.3 sigprocmask与sigpending管理屏蔽字与查询未决信号屏蔽字是进程级的一个位图表示哪些信号被暂时搁置。相关操作封装在几个sigset_t操作函数里#include signal.h int sigemptyset(sigset_t *set); // 全清0 int sigfillset(sigset_t *set); // 全置1 int sigaddset(sigset_t *set, int sig); // 把某位置1 int sigdelset(sigset_t *set, int sig); // 把某位清0 int sigismember(const sigset_t *set, int sig); // 测试信号是否在集合中sigprocmask(int how, const sigset_t *set, sigset_t *oldset)的how有三种SIG_BLOCK将set并入当前屏蔽字、SIG_UNBLOCK从当前屏蔽字去除、SIG_SETMASK用set完全替换当前屏蔽字。实际开发里最常见的使用模式是进入临界区前SIG_BLOCK屏蔽关键信号出来时SIG_SETMASK恢复旧屏蔽字。注意恢复的时候要存好oldset不要直接用SIG_UNBLOCK去解因为你不知道之前还有什么被屏蔽着。sigset_t newmask, oldmask; sigemptyset(newmask); sigaddset(newmask, SIGINT); sigprocmask(SIG_BLOCK, newmask, oldmask); // 屏蔽SIGINT保存旧值 /* 临界区 */ sigprocmask(SIG_SETMASK, oldmask, NULL); // 恢复旧屏蔽字sigpending()则是查询当前未决信号集。典型场景是主线程屏蔽了SIGUSR1起了工作线程工作线程发SIGUSR1告诉主线程有任务——主线程在下次sigsuspend或其他处理点先sigpending()看看有没有积压的信号再统一调度。这个函数还能用于判断某个信号是否确实被屏蔽了没丢还是已经完全递送过了。3.4 sigaction的进阶sa_siginfo与伴随数据当sa_flags里带了SA_SIGINFO时处理函数签名变为三参数版本void handler(int signo, siginfo_t *info, void *context);siginfo_t里能拿到发送信号的进程PID、UID、用户态/内核态来源、以及用sigqueue()发送时携带的附加数据si_value。这让信号从一个纯粹的通知变成了可以传数据的消息。但注意这也意味着信号处理函数内不能调用任何非异步安全函数non-async-safe比如malloc、printf、strcpy等一概不能直接调用。所以实际工程中往往不在handler里做业务逻辑而是只把si_value通过管道写出去然后交给主事件循环处理。这个信号写管道、通知主循环的套路就是经典的self-pipe trick后面第5节还会展开谈。4. 可靠信号、永久阻塞与等待sigsuspend与实时信号4.1 sigsuspend原子地改屏蔽字并等待进程里最常见的等待信号方式是while (flag 0) pause();但这是个有缺陷的写法。如果在flag检查与pause之间信号已经来了pause就会一直挂过去再也醒不来——经典的竞态窗口。POSIX给出int sigsuspend(const sigset_t *mask)来解决它会用一个临时屏蔽字替换当前屏蔽字并挂起进程直到捕获一个信号处理完回来后自动恢复原来的屏蔽字。整个替换屏蔽字挂起等待信号的过程是原子的不存在窗口期。实际场景比如主程序等一个SIGCHLD先屏蔽SIGCHLD然后检查全局的child_exited标志没变化就sigsuspend(oldmask)原子地解开屏蔽并挂起等待。这样一来无论信号是在标志检查前到来还是之后到来都不会漏掉。sigemptyset(newmask); sigaddset(newmask, SIGCHLD); sigprocmask(SIG_BLOCK, newmask, oldmask); while (!child_exited_flag) { sigsuspend(oldmask); } sigprocmask(SIG_SETMASK, oldmask, NULL);这是一段能复现的模板代码。一个注意点sigsuspend返回时进程一定会先执行完信号处理函数然后sigsuspend才返回并恢复屏蔽字。所以你可以放心地把这段写在循环里处理完SIGCHLD后回到循环检查标志逻辑是连贯的。4.2 实时信号排队、携带数据、按序递送标准信号的不排队问题在某些场景下致命比如嵌入式或高精度控制类程序里事件来得密集如果两个同样的事件信号被合并成一个业务就漏了。实时信号就是为此生的。编号范围从SIGRTMIN到SIGRTMAXLinux上是34到64特点每个实时信号有独立队列不会合并发送时通过sigqueue(pid, sig, union sigval value)携带一个整数或指针递送顺序编号较小的优先同号时先发先到处理函数里通过siginfo_t-si_value取回附带数据因为数量从1到31的映射是固定的使用实时信号时不要直接用宏SIGRTMIN0之类的来做业务语义而应该用#define REQ_EVENT (SIGRTMIN3)这种给每个业务含义留一个偏移便于调试时按编号反查。需要注意小于SIGRTMIN的信号不会排队这仍然是个隐蔽坑——如果程序里用了SIGUSR1但期望它排队到头来还是会丢事件。4.3 信号处理函数里到底能不能调库函数这是UNIX编程里被问烂了的问题。严格说信号处理函数里只能调用async-signal-safe函数。POSIX给了明确清单read、write、open、close、waitpid、sigaction、sigprocmask、sigpending等都是安全的而malloc、free、printf、sprintf、fopen、getpwnam、gmtime这些绝不要使用。原因在于这些函数内部维护静态缓冲区、锁或者堆分配状态而信号处理函数是在进程主执行流随机的某个点插入执行的——如果正巧主程序执行到malloc内部刚取到堆锁信号打断它handler再调用malloc那就直接死锁或堆损坏。这是排查信号相关崩溃时的头号嫌疑人。工程上的正确姿势就是handler里只做两件事——把信号相关数据写进一个管道用一个写端然后返回或者置一个volatile sig_atomic_t标志。主循环用poll/select监听管道读端一读到数据就知道有信号来了再在普通上下文里安全地做完整业务处理。这既绕开了异步安全限制又能做更复杂的逻辑。5. 实战守护进程优雅重启、SIGCHLD处理与经典坑排查5.1 用SIGHUP实现守护进程重读配置传统UNIX服务约定里SIGHUP是终端挂断信号但后来成了一个通用习惯守护进程收到SIGHUP就重新打开日志、重读配置文件。Nginx、Apache都是这么干的。在不重启进程的情况下热载配置用户体验好得多——线上那么多长连接不能为了改一个日志级别就把所有连接断开。实现时骨架就这么几行signal(SIGHUP, sighup_handler)注册handler里置g_reload_flag 1。主循环每次结束后检查flag若为1则重新加载配置、重开日志FD。加载配置过程要小心如果新配置解析失败应保留旧配置继续运行并使用日志记录而不是直接崩溃退出。坑也明显如果没配置SA_RESTARTSIGHUP可能会中断原本阻塞的recv()返回EINTR连接表现得像故障。所以注册时务必加SA_RESTART。而对某些服务你又确实希望SIGHUP能打断阻塞IO好让进程快速重读配置——这就要看业务取舍一般主流方案是SA_RESTART 主循环定期检查flag。5.2 SIGCHLD与僵尸进程waitpid的正确用法子进程退出后如果父进程不调用wait子进程会成为僵尸进程占着PID和内核进程表项。处理SIGCHLD的标准范式是handler里调waitpid收尸void chld_handler(int sig) { int status; pid_t pid; while ((pid waitpid(-1, status, WNOHANG)) 0) { /* 记录子进程退出状态 */ } }这里注意三点必须用WNOHANG如果在信号处理函数里阻塞等一个不存在的子进程等于把自己挂死。循环调用waitpid直到返回0或-1因为可能有多个子进程同时退出而SIGCHLD只会来一次靠循环收干净。网上不少老代码在handler里直接wait(status)在子进程少时勉强能用但子进程多或者并发退出多时等着等着信号又被同类的SIGCHLD覆盖掉僵尸就越攒越多。除了在handler里waitpid还有一种更现代的做法屏蔽SIGCHLD并配合signalfdLinux专有将信号事件转化为文件描述符可读事件放进epoll统一调度这样就不用在信号上下文里做任何逻辑安全性更高。5.3 信号导致的经典故障EINTR、竞态与丢失EINTR故障是最普遍的。很多新手服务第一次压测崩溃gdb进去一看发现epoll_wait返回-1errno为EINTR当场就当一个致命错误退出了服务。实际上这根本不是错误——只是有信号打断了等待。正确姿势是循环重试或者跳过继续跑。下面这段是很多框架里常见的处理模式for (;;) { n epoll_wait(epfd, events, maxevents, timeout); if (n 0) { if (errno EINTR) { continue; } /* 真正出错才break */ break; } /* handle events */ }竞态丢失更容易出现在用户按了两下CtrlC的场景。第一下触发handler置flag第二下如果handler还没跑完且信号是标准信号就被合并了看起来像没处理第二次。要保底就用实时信号并加队列或者用signalfd走事件驱动才能严格保证一次也不丢。屏蔽字被意外清空也遇到过。一些第三方库在内部调sigprocmask时用了SIG_SETMASK并传了它自己的set把调用方之前屏蔽的信号全解了。排查这种问题要用sigprocmask(0, NULL, cur)随时读取当前屏蔽字比对或在库周围设置保护性屏蔽必要时要看库的源码看它到底动了什么。5.4 多线程程序里信号的特别规则多线程引入了一个饶人的矛盾传统UNIX信号是进程级的而线程有自己的屏蔽字。内核在投递信号时逻辑是进程定向信号比如kill(pid, sig)会投递给进程内任意一个不屏蔽该信号的线程如果有多个内核选一个。线程定向信号pthread_kill(tid, sig)精确投递给指定线程和进程无关。同步信号硬件异常如SIGSEGV总是到达产生它的那个线程。这个设计带来两个实践要点在主线程创建子线程之前把不希望子线程乱处理的信号全部屏蔽掉然后子线程里自己解屏蔽。否则SIGINT可能被随机投到某个工作线程它直接按默认动作退出进程你主线程连清理的机会都没有。推荐的做法是单线程处理信号。即整个进程所有线程都屏蔽某组信号只留一个专职线程调用sigwait()或sigwaitinfo()等待并处理信号。这等同于把信号全部转成了同步IO事件逻辑清晰也彻底避开了异步安全函数的问题。sigwait()在与signalfd对比时各有优劣sigwait多了可移植性POSIX标准signalfd结合epoll更顺手但仅限Linux。对Linux服务器开发我个人偏好signalfd并把它注册进epoll——因为服务的主循环本身就跑在epoll上多一条可读文件描述符不需要额外引入pthread。5.5 信号与IO多路复用的和谐共处self-pipe模板由于信号处理和业务逻辑不能混在一起信号其实有两条路可以走signalfd把信号转成fdself-pipehandler里写管道主循环里读管道self-pipe是最古老的经典方案跨平台无依赖核心代码如下static int sigpipe_fds[2]; void signal_handler(int sig) { unsigned char byte (unsigned char)sig; /* 非阻塞写如果管道满了丢弃即可反正信号编号能凑合 */ ssize_t unused __attribute__((unused)); unused write(sigpipe_fds[1], byte, 1); } void setup_signals(void) { pipe2(sigpipe_fds, O_NONBLOCK); /* 需要 fcntl.h非Linux可先pipe再fcntl */ struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler signal_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; sigaction(SIGINT, sa, NULL); sigaction(SIGTERM, sa, NULL); }主循环里就把sigpipe_fds[0]跟其他网络fd一起丢给poll/epoll读到数据先读出来按byte取信号编号再执行对应业务逻辑。这套路比较好的地方是handler里只做了一个writewrite是异步安全的主循环的IO框架天然就能响应信号多个信号密集来也不会丢——它们都变成了管道里的字节。注意管道如果满了write返回-1并设EAGAIN此时宁可丢字节也不能去阻塞handler否则又是一个新坑。真担心丢就把一号写端改成容量足够大的环形队列或用SOCK_SEQPACKET一个字节一个包。6. 常见问题排查与实测结论6.1 排查信号问题时的入手步骤在线上围观一个诡异问题时先别急着怀疑信号用下面的顺序过一遍基本能定位大多数情况看产生信号方的来源。gdb里handle SIGxxx print nopass挂起或者用strace -e signal跟踪系统调用和信号递送过程能直观看到什么信号、什么时候来、来自哪个进程。查是否屏蔽导致迟到。sigprocmask(SIG_BLOCK...)如果屏蔽忘了解除信号会一直pending到sigsuspend或进程退出那一刻表现就是好像一直没收到信号。查处理函数里是否有非异步安全调用这类问题最常见、最难复现一旦发生在线上就等于段错误或死锁。用-D_FORTIFY_SOURCE加ASAN跑测试压力也难以保证全覆盖。稳妥的做法是让handler内容最小化。查系统调用是否被EINTR中断。搜索代码里所有可能返回EINTR并直接当错误处理的地方尤其epoll_wait、read、write、sem_wait。查多线程场景下信号到底投给了谁。用pthread_kill明确线程定向或者在主线程统一屏蔽、单独线程sigwait。6.2 信号丢失的实测观察有一段测试能直观感受标准信号的合并行为连续调用kill(getpid(), SIGUSR1)一百次注册的handler里用一个计数器累加。实测结果在常见Linux内核上计数器往往远小于100比如几次循环下来只增加了十几个。这是因为循环跑得飞快第一波信号还在未决状态后面来的同号信号全部合并内核最多在返程时递送一个未决信号。如果你真的需要一个都不能丢必须换成实时信号SIGRTMIN系列配合sigqueue发送。还有一次踩了SIGUSR1不定时出现但计时器不够准的坑最后发现alarm只支持秒级且同一时刻只能有一个换成setitimer或timer_create时钟ID才解决。这种精度不足导致的误差实际上不是信号机制的问题是定时器API选型问题但看到现象容易误判成信号延迟。6.3 不可重入与静态缓冲区的元凶分享一个真实案例有一套天级任务调度服务某天突然变量全局乱掉、偶发段错误gdb core里看到调用栈停在localtime内部。处理函数里原本就调用了localtime去打印时间日志而主程序某个线程在另一个地方也调用localtime它内部用了静态struct tm缓冲区两个执行流一交叉缓冲区数据覆盖程序就彻底乱套了。改法很简单handler里不再打印时间只置标志位日志交给主循环统一打。教训就是信号处理函数里时间函数一个都别用localtime、gmtime、asctime全都不安全。要用就用localtime_r这种线程安全版即便如此也要在handler外调用。类似地getpwuid取用户信息内部有静态缓冲区syslog底层也有锁strerror依赖静态数组全都不能碰。写handler的黄金法则是只读写用volatile sig_atomic_t声明的变量保证读取或写入是原子指令再加一个write调用。其他任何事都放外面。7. 最后再分享一个小技巧从实际项目中总结出一个小建议给你的信号handler保留一张全局信号名映射表。代码里可能用#define SIG_CONF_RELOAD (SIGRTMIN2)这种别名但排障时gdb或strace里看到的是编号。写一个debug辅助函数出问题时把编号翻译成有意义的字符串让日志信息能直接说SIG_CONF_RELOAD received而不是signal 36 received。光这一个习惯处理起线上奇怪问题时节省的时间是按天计的。还有一点是别怕把信号逻辑复杂化但也不要动不动就引入实时信号和细粒度屏蔽。信号机制没有银弹它本身就是80年代延续下来的设计工程上最重要的是简单可预期的处理路径。我在多个高并发服务里验证过主循环全都是epoll signalfd/self-pipe 少量sigaction注册一个信号处理函数绝不超过5行逻辑全都拉到主循环里做——这组合用起来既稳定又容易排查。遇到信号相关的Bug时心态要稳记住三个排查方向屏蔽字、异步安全、重入竞态大多数坑都能从这里头找出答案。
阅读完成 · 觉得有帮助?
咨询建站