ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux信号捕捉与中断机制:从sigaction到内核借道返回

Linux信号捕捉与中断机制:从sigaction到内核借道返回 这是Linux进程信号系列的第三篇。前两篇把信号的产生、发送和默认处理动作讲透了这篇终于来到重头戏——信号的捕捉以及绕不开的一个概念集合中断。很多初学者会把信号和软件中断当成一回事或者用信号是软件中断一句话敷衍过去。但你只要深挖过一次硬件中断、软件中断、时钟中断和异常的关系就会发现信号、系统调用、定时器、异常处理其实是一张网而这篇文章的核心就是把这张网拆开看清楚信号捕捉这最后一公里是怎么走完的。这篇文章适合两类人一是学完了信号基本概念想在进程层面理解我写的那个signal函数到底注册了什么的开发者二是被SIGINT、SIGSEGV、SIGALRM这些信号搞到头大想从底层机制角度彻底把它们理顺的运维或内核入门者。我会把信号捕捉流程、sigaction的每个字段、信号处理函数的安全红线、四类中断的本质以及从硬件事件到信号的完整链路全部串起来讲中间附带可直接复现的实验代码和踩坑记录。1. 信号捕捉的底层机制从内核态到用户态的一次借道返回1.1 递达与捕捉信号处理函数为什么是在用户态执行的理解信号捕捉首先得纠正一个根深蒂固的直觉错误很多人以为内核捕获到信号之后会在内核态直接调用我们写的handler函数。事实不是这样内核态没法直接调用用户空间的函数因为那涉及特权级切换、栈切换、地址空间安全性等一系列问题。正确的做法是内核把信号的相关信息保存在进程的task_struct里当进程从内核态返回用户态的那一刻内核会检查当前进程有没有未决pending且没有被阻塞的信号。如果有并且进程为这个信号注册了自定义处理函数内核就临时修改用户态的执行上下文让CPU跳到你的handler入口去执行。你可以把整个过程想象成一次借道返回进程原本因为某次系统调用、中断或异常进入了内核态办完事准备回用户态继续执行。正常情况下返回的地址是系统调用下一条指令但如果前面有几个没处理的信号等着内核就会在这条回家的路上截个胡帮你把执行流拐到信号处理函数上。这个设计关键在于信号处理函数始终运行在用户态。内核只负责搭建舞台真正唱戏的是用户程序自己的代码。这样权限边界很清楚信号处理函数里你依然受用户态的所有限制不能直接操作硬件、不能访问内核内存该段错误照样段错误。1.2 从sigpending检查到sigreturn一次信号捕捉的完整步骤为了让大家看得更清楚我把一次完整的信号捕捉过程拆成七步。以进程正在执行一段普通的用户态代码此时一个SIGINT递达为例进程正在用户态执行指令此时CPU收到中断或者进程发起系统调用陷入内核态。内核在返回用户态之前检查当前进程的pending信号位图发现有未被屏蔽的SIGINT。内核查进程的sigaction表发现SIGINT注册了自定义handler于是准备接管当前进程的用户态上下文。内核把当前用户态寄存器的值、程序计数器、栈指针、状态字等信息保存到进程的内核栈中然后修改用户态栈压入一个特殊的返回地址sigreturn的地址以及信号相关信息。内核恢复现场返回用户态但这一次的返回地址被替换成了handler的入口地址。CPU开始执行你的信号处理函数。你的handler执行完毕后函数返回会跳到步骤4里顺手布置的那个返回地址上这个地址其实是内核提供的sigreturn系统调用入口。程序再次陷入内核态内核从内核栈中取出之前保存的用户态上下文恢复现场返回到用户态原本被信号打断的那条指令继续执行。这个七步走里很多人第一次没看明白的是第4步和第6步为什么handler返回之后能完美恢复现场靠的就是sigreturn这个隐蔽的系统调用。你要是用strace去跟踪一个被信号打断的进程在信号处理前后经常能看到rt_sigreturn这个系统调用就是它把整个现场圆回来的。这一步也是后面gdb调试信号处理函数时容易迷茫的地方值得先有个印象。2. 注册处理函数signal()的坑与sigaction()的完整参数2.1 signal()的历史包袱不同Unix的语义伤疤signal()可能是Unix世界里存活时间最长但最不容易用好的接口之一。早期System V和BSD对signal()的语义定义有严重的分歧而这两个分歧恰好都戳中了信号捕捉最关键的痛点。System V的语义是当某个信号的处理函数被触发的瞬间内核会先将该信号的处理动作还原为默认行为SIG_DFL再去执行你的handler。这意味着如果你在处理函数里还想继续接收同一个信号就必须在handler开头重新调一次signal()注册自己。这个执行一次即失效的设计在信号风暴来临时极度容易出问题——两个信号几乎同时到达第二个信号直接触发了默认动作进程直接被杀了。BSD的语义则更接近我们今天的直觉处理函数注册一次永久生效而且handler执行期间自动屏蔽当前信号避免递归嵌套。Linux的signal()在glibc里是用sigaction()实现的行为更接近BSD语义但不要因此就放心大胆地用。问题在于signal()没有提供任何方式去设置更多选项比如是否自动重启被中断的系统调用、handler执行期间要不要屏蔽额外信号、是否需要接收信号的详细来源信息。这些需求一旦出现你还是得回到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就是我们熟悉的handler函数指针。但注意它是个联合体性质的字段当sa_flags里设置了SA_SIGINFO标志时你要用sa_sigaction而不是sa_handler后者的函数签名能拿到siginfo_t结构体里面包含了信号编号、发送者PID、发送者UID、产生信号的原因、具体地址等丰富信息。sa_mask在handler执行期间需要额外屏蔽的信号集合。注意当前正在处理的信号默认也是被屏蔽的不需要你自己往sa_mask里加。这个默认屏蔽行为由内核保证等handler执行完恢复现场时自动解除。sa_flags这一项最关键下面单独开一节讲。sa_restorer这是老内核遗留下来的现代Linux基本不需要开发者关心它被内核用于布置sigreturn的返回地址。我在第一章提到的借道返回里那个神奇的返回地址早期就是靠这个字段设置的。现在你把它置为NULL即可或者干脆不初始化别乱填。2.3 sa_flags每一项的真实用途sa_flags是信号捕捉里最容易让人感到困惑的参数这里把最常用的几个选项一次说透标志作用什么时候用SA_RESTART被信号打断的系统调用自动重启处理read/write等系统调用退出时不想处理EINTR错误SA_NODEFERhandler执行期间不屏蔽当前信号极小概率使用需要允许信号递归打断自己SA_RESETHAND执行一次后恢复默认行为基本不推荐等于手动复刻System V语义SA_SIGINFO使用sa_sigaction回调拿信号的详细来源信息需要区分同一种信号的不同触发原因时这里重点展开SA_RESTART因为它是我在实际项目里踩过坑的地方。场景是这样的程序主流程调用read阻塞在标准输入上等着用户输入。此时进程收到SIGINT信号处理函数执行完毕返回之后read会发生什么如果没有SA_RESTARTread会被中断并返回-1errno EINTR。你的主流程代码如果不判断EINTR直接把read失败当成致命错误处理就会产生莫名其妙的退出或异常行为。有SA_RESTART的情况下内核会在sigreturn恢复现场之后自动重新发起被中断的系统调用对应用层来说read就像从未被打断过一样。看起来省心但它带来的副作用是如果你本来想用信号去中断一个阻塞的系统调用比如多线程里用信号唤醒阻塞线程SA_RESTART会把这个中断吞掉让你的信号变得毫无反应。所以在写网络服务器或者需要精确控制阻塞逻辑的程序时到底用不用SA_RESTART一定得想清楚。接下来给一段最标准的sigaction注册模版我自己的项目里一直用的就是这套写法#include stdio.h #include string.h #include signal.h #include unistd.h #include stdlib.h static void handler(int sig) { write(STDOUT_FILENO, caught signal\n, 14); } int main(void) { struct sigaction act; memset(act, 0, sizeof(act)); act.sa_handler handler; sigemptyset(act.sa_mask); // 不额外屏蔽任何信号 act.sa_flags SA_RESTART; // 按需选择注意系统调用重启问题 if (sigaction(SIGINT, act, NULL) 0) { perror(sigaction); exit(1); } while (1) { pause(); // 挂起等待信号 } return 0; }注意我专门用了write而不是printf来输出这里牵涉到下一章要讲的信号安全红线先留个引子。3. 信号处理函数的安全红线可重入、异步安全与errno3.1 可重入函数为什么printf会在信号处理里出事信号处理函数最阴险的地方在于它可能在程序执行的任意位置被插入。你的主程序运行到一半寄存器、栈、堆的状态全在某个中间状态内核直接切过来执行你的handler。等handler结束再回到主程序那个中间状态继续执行。这就意味着handler里调用的一切都必须保证跟主程序当前的状态不会产生冲突。这里要理解一个关键概念可重入。一个函数在任意时刻被中断后再次进入都不会破坏数据一致性就称为可重入。printf为什么不可重入因为libc的缓冲区和可变参数机制内部用到了全局状态和锁。如果主程序正在调用printf的过程中分配了锁信号打断它handler里又调printf去抢同一把锁好了死锁程序挂在那里一动不动。哪怕主程序恰好没在printf里这里的static缓冲也可能被写乱导致输出错乱。我见过很多初学者在信号处理函数里用printf看起来没问题多跑几次、加大输出量之后忽然就崩了就是这个原因。信号安全不是你运气好没触发问题而是触发问题时程序已经坏了。3.2 异步信号安全函数清单与errno保护那什么样的函数能在信号处理函数里用POSIX标准列了一张async-signal-safe函数清单核心思路是这些函数要么只用局部状态要么操作本身由内核保证原子性。日常最常用到的read / writeopen / closefork / execve / _exitkill / raisesigaction / sigprocmask / sigemptyset 等信号操作函数getpid / getuid 等简单的查询类系统调用反之malloc、free、printf、sprintf、snprintf、fprintf、stdlib里的大多数函数、locale相关的函数、以及任何涉及锁的线程库函数全都不在安全名单里。原因很简单它们要么有内部锁要么有全局状态要么可能触发系统调用嵌套导致更复杂的上下文问题。还有一点很容易被忽略errno的保护。信号处理函数里调用的合法函数比如write本身完全可能设置errno。如果主程序本来在检查某次系统调用失败的原因信号一打断handler里的write把errno改成了别的值回到主程序之后主程序拿到的errno已经不是它自己系统调用失败时设置的那个了。这种情况没有编译告警没有运行时错误就是结果偶尔不对排查起来极其痛苦。解决办法也很简单在handler开头保存errnohandler结束前恢复static void handler(int sig) { int saved_errno errno; write(STDOUT_FILENO, caught\n, 7); errno saved_errno; }3.3 一个死锁场景的复盘malloc与锁我早期写过一段代码主程序不停地用malloc分配内存同时在信号处理函数里将收到的消息拼接到一个全局链表中也用了malloc。结果程序在压力测试下经常卡死。用gdb看的时候进程停在malloc内部的一个锁上主流程和handler各持着一部分锁状态谁也抢不到对方手里的锁经典的自死锁。这个案例的教训不止是别在handler里调malloc更要理解为什么很多人写了类似代码却偶尔能跑很久。关键在malloc的实现细节glibc的malloc面对不同线程和不同分配大小会走不同的分配路径。有的路径完全不碰主分配区的锁有的要碰所以你的程序是有时候安全、有时候危险这种时隐时现的bug最难查。正确做法是把信号处理函数当成一个轻量触发点只做三件事设置标志位、向管道写一个字节通知其他线程、或者把数据临时放入无锁环形缓冲区由主循环统一处理。我在自己项目里的规范是——handler里第一行就是volatile sig_atomic_t flag 1;所有复杂逻辑全部放到主循环里处理。4. 四类中断的本质差异硬件中断、软件中断、时钟中断与异常4.1 硬件中断外设与CPU之间的敲门说完信号捕捉的微观流程接下来把话题拉到中断这个大概念上。为什么信号和中断容易混因为它们在中文语境下都叫xx中断但实际上两者位于计算机系统的不同层次。中断是CPU和硬件协同工作的机制信号则是进程间通信和内核通知进程的机制。从硬件事件到信号中间隔着整个内核但很多人没意识到这层关系。先说硬件中断也叫外部中断、异步中断。它的特点是外设主动通知CPU与CPU当前正在执行的指令没有时间先后关系。比如网卡收到数据包、键盘被按下、硬盘DMA完成这些设备通过中断控制器x86上是APIC给CPU发送一个中断请求。CPU在执行完当前指令后检查到有中断需要处理就保存当前的执行现场跳转去执行对应的中断处理程序。这类中断最大的特征是异步性你不知道它什么时候来。所以硬件中断的处理程序必须非常短把最紧要的事情做完剩下的大量工作推给下半部机制比如Linux的softirq、tasklet、workqueue去处理。很多初学者在没接触内核代码前以为网卡每收一个包CPU都要停止当前工作去处理很久实际上是中断处理程序快速把数据从硬件寄存器搬到内存然后软中断负责真正的协议栈处理。4.2 软件中断系统调用与内核软中断软件中断这个词其实承载了两个层次的含义得拆开看。第一个层次是CPU指令层面的软件中断x86上用int指令主动触发一个中断向量。最经典的例子就是系统调用入口。早年的Linux通过int 0x80让用户态程序陷入内核态本质是用户程序主动触发了一次软件中断。现代CPU有syscall指令性能更高但概念上的本质一致用户态进程主动向内核请求服务CPU切换到内核态执行处理程序执行完再返回用户态。第二个层次是Linux内核里专门的一套机制——软中断softirq。这套机制是硬件中断处理程序的下半部用于处理那些不紧急但量很大的工作。比如网络数据包的接收处理、块设备IO的完成处理很多都通过软中断延后执行。它的关键设计在于让硬中断处理程序尽快释放CPU而软中断可以在稍微宽松的环境里慢慢处理数据。这两层软件中断和信号的关系都很密切。系统调用层的中断是用户态到内核态的桥梁信号捕捉的整个过程就依赖这条桥内核软中断则是很多硬件事件通向进程通知的中间加工环节。4.3 时钟中断系统的心跳时钟中断可能是最容易跟信号产生关联的部位。处理器上有一个可编程定时器如x86的APIC timer、高精度事件定时器hrtimer它会每隔一段时间向CPU发出一次中断。你可以把时钟中断想成整个操作系统的心跳操作系统靠它来计量时间片、切换进程、统计CPU使用率、维护各种定时器和延迟队列。这里要说清一个容易混淆的点时钟中断本身不直接产生进程信号它是所有定时类信号的发动机。你在用户态调用alarm(3)内核会为进程注册一个定时事件并把这个定时事件挂在时钟中断驱动的时间轮或高精度定时器队列上。当时钟中断到来时内核检查是否有到期的事件发现你这个进程的定时器到期了于是生成SIGALRM标记为进程的未决信号。下一次进程从内核态返回用户态时就走完了第一章说的信号捕捉流程。所以你看时钟中断和信号的关系是内核基础设施和用户态感知的关系。进程不需要关心时钟中断具体什么时刻来它只感知到SIGALRM到了而背后帮它数时间的正是时钟中断。4.4 异常故障、陷阱和中止异常和硬件中断最大的区别在于异常和CPU正在执行的指令强相关。不是设备主动敲门而是指令自己执行出了问题或者需要辅助。x86上异常分三类故障Fault指令执行过程中发现问题但CPU保留现场后转去处理处理完可以重新执行这条指令。最典型的是缺页异常page fault。访问的内存页不在物理内存中CPU触发Page Fault异常内核把数据页从磁盘调入物理内存然后重新执行访问指令这条指令在内存就绪后就能正常完成了。除零错误#DE在x86上也属于故障但内核处理不了这种情况的修正于是转手就给当前进程发了SIGFPE信号。陷阱Trap指令主动触发用于请求内核服务或调试。x86上的int 3断点指令就是典型的陷阱调试器用它让进程停下来并将控制权交给调试器。断点异常触发后内核会向被调试进程发送SIGTRAP调试器接收并处理。中止Abort系统检测到严重错误比如硬件故障或者内核数据结构被破坏无法恢复。这种情况下CPU不会回到产生异常的指令继续执行。x86上的机器检查异常#MC就属于中止类内核通常会记录错误日志后终结系统或进程。异常和信号的对应关系是理解硬件事件如何变成进程事件的最好切入口。下面用一章讲三条完整的链路。5. 从硬件事件到进程信号三条完整链路5.1 键盘按下CtrlCSIGINT是怎么一路跑到进程的这是最经典的整链路案例把键盘这个硬件外设一直到应用层的信号处理函数串起来。硬件层用户按下CtrlC键盘扫描电路产生中断信号键盘控制器通过中断线向CPU报告。CPU响应中断执行键盘驱动注册的中断处理程序把扫描码从控制器寄存器读出来转换成键值扔进内核的输入子系统缓冲区。内核层输入子系统把键值转发给当前关注键盘的tty驱动。tty的线路规程line discipline在字符链路里检查这个字符是不是特殊字符——如果是INTR字符通常是ASCII 03线路规程会做两件事一是把收到的输入丢弃不写入用户空间读取缓冲区二是向当前连接这个终端的前台进程组发送SIGINT信号。进程层当前台进程有自定义SIGINT处理函数时信号递达进程从内核返回用户态时转入handler执行。如果进程没有自定义handler默认动作就是终止进程。这就是一个完整外设事件到进程信号的传递链路。这条链路最大的启示是信号并不是CPU收到键盘中断后直接发给进程的这中间经过了内核子系统层层处理、语义转换。驱动负责硬件数据线路规程负责把字符翻译成信号动作调度系统负责任务切换。各司其职信号只是最上层那个给进程看的结果。5.2 除零如何变成SIGFPE再来看看由异常触发的一条链路。用户态程序写了一个整数除以零int a 42; int b 0; int c a / b;CPU执行div指令时检查到除数为0触发编号为0的异常#DE。此时CPU保存现场跳转到IDT里向量0对应的处理函数。内核的divide_error处理函数发现这是一个用户态进程产生的错误于是调用force_sig_fault相关机制向当前进程发送SIGFPE信号并且带上siginfo_t信息说明错误地址是哪条指令、原因是什么FPE_INTDIV。默认情况下SIGFPE的默认动作是终止进程并生成core文件。你在服务器日志里看到的那种崩溃日志很多就是这类异常触发的。而如果你给SIGFPE注册了handler就能在除零发生后执行自定义逻辑比如记录日志、保存现场、或者优雅退出。需要注意SIGFPE这类同步信号和SIGINT这类异步信号性质不一样。同步信号总是由某条具体的指令触发和进程的当前执行位置强绑定异步信号则随时可能到达。区分这个性质对编写处理函数很有用——同步信号的处理函数往往可以通过sigaction的SA_SIGINFO参数拿到siginfo_t.si_addr精确知道是哪个地址出了问题这对崩溃分析和日志审计非常有价值。5.3 alarm如何利用时钟中断触发SIGALRM最后看一条由时钟中断间接驱动的链路。用户调用alarm(5)内核会为当前进程注册一个相对定时器到期时间设为5秒后的jiffies或高精度时间点。同时内核调低当前进程的某些调度权重防止sleeping太久浪费CPU。注意这个定时器是挂在系统全局的定时器管理机制上的不是一个独立的硬件定时器。时钟中断周期性到来时内核时钟代码检查全局定时器队列看哪些定时器已经到期。发现进程的alarm定时器到期了就调用信号发送逻辑把SIGALRM标记为进程的pending信号。进程从内核态返回用户态时走完信号捕捉流程执行handler。这里有一个很实用的细节如果你想实现每隔固定时间执行任务的功能很多初学者的第一个想法是循环里不断调用alarm。但alarm是单次定时器到期后需要再次调用才能继续定时。这就要特别注意在handler里重新设置定时器的时序问题如果handler执行时间较长下一次alarm的期限可能在handler执行期间就到了这时信号会被屏蔽吗前面提到默认情况下当前信号在处理期间会被自动屏蔽所以如果你在handler末尾重新设alarm下一次信号会等当前handler执行完再递达实际周期会被拉长。更精确的做法是用setitimer的ITIMER_REAL模式这种模式支持周期性定时内核到点自动重新加载定时器值不用你手动在handler里续。#include stdio.h #include signal.h #include string.h #include sys/time.h #include unistd.h static void alarm_handler(int sig) { write(STDOUT_FILENO, tick\n, 5); } int main(void) { struct sigaction act; struct itimerval timer; memset(act, 0, sizeof(act)); act.sa_handler alarm_handler; sigaction(SIGALRM, act, NULL); timer.it_interval.tv_sec 1; // 周期 timer.it_interval.tv_usec 0; timer.it_value.tv_sec 1; // 首次到期 timer.it_value.tv_usec 0; setitimer(ITIMER_REAL, timer, NULL); while (1) { pause(); } return 0; }这个例子里setitimer的it_interval参数让内核周期性地发送SIGALRM这种由内核维护周期性信号的机制稳定性远超你在handler里自己续alarm的版本。6. 实测信号捕捉demo、strace与gdb6.1 最小Demo与编译验证光讲原理还不够把代码跑起来、看到信号捕捉的完整流程印象会完全不同。先写一个最小验证程序捕获SIGINT并统计次数#include stdio.h #include signal.h #include string.h #include unistd.h #include stdlib.h static volatile sig_atomic_t g_count 0; static void handler(int sig) { g_count; write(STDOUT_FILENO, caught SIGINT\n, 14); } int main(void) { struct sigaction act; memset(act, 0, sizeof(act)); act.sa_handler handler; act.sa_flags SA_RESTART; sigemptyset(act.sa_mask); sigaction(SIGINT, act, NULL); while (1) { pause(); if (g_count 3) { write(STDOUT_FILENO, goodbye\n, 8); exit(0); } } return 0; }编译运行gcc -g -o signal_demo signal_demo.c ./signal_demo在另一个终端用kill -INT pid发信号或者直接在当前终端按CtrlC。程序会打印三次caught SIGINT后退出。这里说一下为什么用pause而不是sleeppause直接挂起进程等待信号语义干净不会跟缓冲区和定时器纠缠。6.2 strace观察信号递达轨迹strace是观察信号机制的利器。用strace跟踪这个demostrace -f -e tracesignal ./signal_demo输出里你会看到程序启动阶段注册信号处理函数的系统调用rt_sigaction(SIGINT, {sa_handler0x401166, sa_mask[], sa_flagsSA_RESTART}, NULL, 8) 0 rt_sigprocmask(SIG_SETMASK, [], NULL, 8) 0 pause(另外开一个终端向进程发送kill -INT pid然后回到strace窗口你会看到关键的几行--- SIGINT {si_signoSIGINT, si_codeSI_USER, si_pid12345, si_uid1000} --- rt_sigreturn() -1 EINTR第一部分是内核打印的信号递达信息包含了siginfo_t里的内容信号编号SIGINT、发送者是用户态的kill命令si_codeSI_USER以及发送进程的PID。第二部分是rt_sigreturn——这正是第一章说的借道返回的第二脚handler执行完之后通过它恢复现场。这里会看到返回-1 EINTR看起来很像个错误其实是正常的因为pause这个系统调用本来就不是被正常返回的而是被信号中断的。如果去掉SA_RESTART你会看到pause直接被中断返回EINTR加了SA_RESTART后内核自动重新pause但strace里依然能看到这次中断发生。很多人第一次看到strace里的rt_sigreturn返回EINTR会吓一跳以为是程序出错了实际上这是信号处理流程里的标准路径。理解了这一层你已经比很多只调API不明原理的人高出一截。6.3 gdb调试信号处理函数调试信号处理函数和调试普通函数有个很大的区别信号可能在任意一条指令打断程序而gdb默认会拦截信号。直接设断点去调试handler会碰到两个问题一是gdb可能在信号到达之前就停在某些不相关的地方二是默认情况下gdb会接管SIGINT导致程序收不到信号。正确的做法是在gdb里明确告诉它怎么处理信号(gdb) handle SIGINT stop noprint passstop信号到达时停住程序。noprint不打印多余提示。pass把信号交给程序处理而不是gdb吞掉。然后给handler设置断点(gdb) break handler (gdb) run在另一个终端用kill -INT pid发信号这里不要用gdb所在终端的CtrlC会跟gdb抢输入。gdb会在handler处停下来你可以用bt看调用栈。这时候栈顶是我们自己的handler再往下的帧不太直观因为内核破坏了普通函数调用的栈结构。如果你用bt全量打印会看到signal handler called这样的标注这就是内核借道返回布置的特殊栈帧留下的痕迹也是理解信号捕捉机制时最容易记住的画面。6.4 复盘我踩过的三个信号坑写到最后按老规矩分享几个我自己在信号处理上反复踩的坑每一个都能写半天的血泪史。第一个坑信号处理函数里用了printf做日志平时没事压力测试一上就随机崩溃。原因前面讲过printf内部有锁和全局缓冲。后来我写了一个自己的日志函数内部只做write到预先打开的fd彻底绕开libc的缓冲问题就再没出现过那种随机的段错误。第二个坑没搞清楚SA_RESTART对阻塞系统调用的影响。公司一个服务里运维通过SIGTERM让进程优雅退出本来期望阻塞在epoll_wait上的工作线程立刻返回去检查退出标志结果设置了SA_RESTART之后epoll_wait被信号重启线程依然卡住退出流程迟迟走不下去。排查了一天才找到是SA_RESTART的锅。所以如果信号是用来唤醒阻塞系统调用的千万不要开SA_RESTART。第三个坑多线程环境里信号处理异常复杂。信号是发给进程的但进程里哪个线程来执行handler是内核决定的这个线程完全可能在拿着锁、正在执行关键业务的中间被打断。如果handler里再去尝试拿另一把锁死锁概率极高。现代代码的规范做法是所有线程先屏蔽信号单独开一个专门的信号线程用sigwait统一处理把信号变成一种正常的业务消息循环。这个模式在Java的JVM、Python的signal module底层都有类似的设计可见它是经过实践检验的路线。信号处理这块我个人的体会是越是在用户空间写了很多年业务代码的人越容易轻视它。因为平时根本遇不到极端情况但生产环境的压力测试和偶发故障专门找这种代码里的隐患下手。把底层的机制彻底搞透养成在handler里只做最轻量操作的习惯能帮你躲开很多只在深夜报警铃声中才暴露的问题。
返回列表