:信号处理期间又来了怎么办?——sigaction、sa_mask、可重入函数与 volatile)
本文收录于「流浪」的系列专栏 Linux系统⚙️ C 数据结构与算法 Python LangChain LangGraph️ MySQL 数据库 Git 工具 计算机网络 AI 大厂面试、八股 学习筑基专栏 博客主页:流浪 原创首发于 CSDN前情:信号(五)讲透了捕捉全流程——do_signal 查账、四次用户态/内核态切换,并埋下一条线:handler 会插在 main 任意两条指令之间执行。本篇兑现这条线:handler 插队之后,会出什么乱子、又该怎么防。三道防线依次是——sa_mask(防重复递达)、可重入判定(防共享数据被破坏)、volatile(防内存不可见)。这是信号六与信号七两篇中的前篇。一、重新认识 sigaction信号(五)里注册 handler 用的是signal(),一句话带过。本篇补上它的完整形态:sigaction。1.1 三个参数:捕捉什么、怎么处理、旧的输出型1 函数原型// 来源:man 2 sigactionintsigaction(intsignum,conststructsigaction*_Nullable restrict act,structsigaction*_Nullable oldact);signum:要捕捉的信号,除 SIGKILL、SIGSTOP 外任意有效信号;act:非 NULL 时,用它安装新动作;oldact:非 NULL 时,先前的处理动作被保存到这里。2 第二个参数:告诉内核怎么处理第二个参数是一个struct sigaction,把处理动作的全部细节一次说清(见 1.2)。对比signal(2, handler)这种一句话注册,sigaction 把处理期间屏蔽谁、用什么标志、按哪种函数原型回调全部开放给调用者。3 第三个参数:oldact,输出型oldact是典型的输出型参数:把该信号上一次的处理动作带出来。典型用法是临时接管信号、处理完再恢复:structsigactionact,oldact;act.sa_handlermy_handler;sigemptyset(act.sa_mask);act.sa_flags0;sigaction(SIGINT,act,oldact);// 安装新动作,旧动作存入 oldact/* ... 临时接管期间 ... */sigaction(SIGINT,oldact,NULL);// 恢复旧动作1.2 struct sigaction:处理动作的完整描述1 字段一览// 来源:man 2 sigaction(经 glibc 封装后的用户可见结构)structsigaction{void(*sa_handler)(int);// 处理函数(简单形式)void(*sa_sigaction)(int,siginfo_t*,void*);// 处理函数(带信息形式)sigset_tsa_mask;// 处理期间额外阻塞的信号集intsa_flags;// 行为标志,按位或void(*sa_restorer)(void);// 不供应用程序使用};2 测试demo#includeiostream#includesignal.hvoidhandler(intsignum){std::coutsignum:signumstd::endl;while(true){sigset_tpending;intnsigpending(pending);for(inti31;i0;i--){if(sigismember(pending,i)){std::cout1;}elsestd::cout0;}sleep(1);std::coutstd::endl;}}intmain(){structsigactionoct,od;oct.sa_handlerhandler;sigemptyset(oct.sa_mask);oct.sa_flags0;sigaction(SIGINT,oct,od);// 对2号信号进行了捕捉, 2,3,4都屏蔽while(true){std::couthello world: getpid()std::endl;sleep(1);}return0;}3 sa_handler 与 sa_sigaction 是互斥的两选一手册明确:某些架构上这两个成员是联合体,不要同时赋值。默认用sa_handler(int);在sa_flags里指定SA_SIGINFO时,改用sa_sigaction(信号, siginfo_t*, 上下文)——能拿到信号是谁发的、从哪发的这类附加信息,本篇不展开。4 sa_flags 与 sa_restorersa_flags是一组按位或的标志(如SA_NODEFER、SA_SIGINFO、SA_RESTART),本篇只涉及SA_NODEFER(二、2.4)。sa_restorer手册原话:“not intended for application use”,应用程序不碰。1.3 signal() 和 sigaction 什么关系1 signal 是 sigaction 的封装glibc 实现层面,signal()内部就是按一套默认 flags 调用sigaction(glibc 源码sysdeps/posix/signal.c,可自行查阅)。所以signal()能做的,sigaction 都能做;反过来不成立——不同 UNIX 平台上 signal() 的语义有历史差异,工程上推荐统一使用 sigaction。2 本篇为什么必须用 sigaction因为本篇要精确控制处理期间阻塞谁——这个能力只在sa_mask里,signal()不开放。二、信号处理期间,同一个信号又来了怎么办2.1 场景:handler 执行中,再按一次 CtrlC1 直觉上的担忧考虑这样的代码:对 2 号信号(SIGINT,即 CtrlC)注册 handler,而 handler 本身执行很久。如果在 handler执行到一半时,用户再按一次 CtrlC,会发生什么?直觉的回答是嵌套:handler 里再进一个 handler,不断递归,耗尽栈和内存。2 实际行为:第二次 CtrlC 只挂号,不执行实际不会递归。第二次 CtrlC 到达内核后,只是在 pending 位图上挂号,并不触发 handler 再次执行。这道防线从哪来?——来自 OS 在安装 handler 后自动生效的一条规则。2.2 OS 的规则:处理期间自动阻塞当前信号1 规则陈述man 2 sigaction 原文:“In addition, the signal which triggered the handler will be blocked, unless the SA_NODEFER flag is used.”——触发 handler 的那个信号,在 handler 执行期间会被自动加入阻塞掩码;handler 返回后,掩码恢复为安装前的状态(POSIX.1 规定)。结合信号(三)的阻塞语义:被阻塞期间,再次到达的同类信号只在 pending 上挂号,不递达;handler 返回、阻塞解除后,补递一次。2 记忆强化:block 的管辖边界这里有一个必须钉死的边界认知:block 不控制信号能不能到达内核,它只管信号能不能递达给用户进程。信号到达内核(外设/别的进程把它记到 pending 位图上)这一步,block 拦不住、也不该拦;从内核递达给用户进程去执行 handler这一步,才归 block 管。把这两步分开,阻塞类问题就再不会混。2.3 sa_mask 的准确作用1 定义:处理期间额外追加的阻塞集合上面 2.2 的自动阻塞只覆盖当前这个信号。sa_mask的价值在于扩大范围:man 手册原文——“sa_mask specifies a mask of signals which should be blockedduring execution of the signal handler”。即 handler 执行期间,sa_mask里指定的信号一并被阻塞,返回后一并恢复。典型用途:handler 要操作一块全局数据时,把可能抢这块数据的其他信号临时挡在门外。2 测试demo#includeiostream#includesignal.hvoidhandler(intsignum){std::coutsignum:signumstd::endl;while(true){sigset_tpending;intnsigpending(pending);for(inti31;i0;i--){if(sigismember(pending,i)){std::cout1;}elsestd::cout0;}sleep(1);std::coutstd::endl;}}intmain(){structsigactionoct,od;oct.sa_handlerhandler;sigemptyset(oct.sa_mask);sigaddset(oct.sa_mask,3);sigaddset(oct.sa_mask,4);oct.sa_flags0;sigaction(SIGINT,oct,od);// 对2号信号进行了捕捉while(true){std::couthello world: getpid()std::endl;sleep(1);}return0;}2 可运行 demo:亲手验证不重入// sa_mask_demo.cpp —— 编译: g sa_mask_demo.cpp -o demo#includesignal.h#includeunistd.hvoidhandler(intsig){write(STDOUT_FILENO,handler 进入\n,15);sleep(3);// 人为拖长,留出连按 CtrlC 的时间write(STDOUT_FILENO,handler 退出\n,15);}intmain(void){structsigactionact;act.sa_handlerhandler;sigemptyset(act.sa_mask);// 不额外追加阻塞(当前信号仍会被自动阻塞)act.sa_flags0;sigaction(SIGINT,act,NULL);while(1)pause();return0;}注意 handler 里用的是write而不是printf——这不是随意选择,原因见第三章。运行后在 3 秒内连按五次 CtrlC,观察输出:进入/退出严格交替,handler 从未嵌套;退出后最多再补递一次。5 次按键,最多换来 2 次执行。2.4 两个边界:SA_NODEFER 与不可阻塞的信号1 SA_NODEFER:主动放弃这层保护man 手册:设置SA_NODEFER后,当前信号不再被自动阻塞,“a further instance of the signal may be delivered to the thread while it is executing the handler”——handler 执行期间同类信号可以再次递达,真·递归成为可能。有极少数场景需要它,默认不用。2 SIGKILL/SIGSTOP 阻塞无效手册 NOTES:SIGKILL、SIGSTOP 无法被阻塞(写进 sa_mask 也一样),“attempts are silently ignored”——尝试会被静默忽略。这呼应信号(四)的结论:kill -9 之所以是最后手段,内核层面没有给任何退路。三、函数被重入:handler 插队的真实代价3.1 两条执行流穿过同一个函数1 场景:main 流与 handler 流都在 insertsa_mask 解决的是同一个 handler 嵌套自己。但 handler 的更大风险在于:它插在 main 的任意两条指令之间执行。设 main 正在向一条全局链表插入节点,handler 恰好在这时递达,而 handler 里也在往同一条链表插入——同一个insert,被两条执行流先后进入,这就是函数被重入。2 断裂点:两次赋值之间头插法只有两步:node_t*nmalloc(sizeof(node_t));n-valx;n-nexthead;// ← 断裂点:执行完这行,若信号递达headn;// ← 这行还没执行n-next head之后、head n之前,是插不进去也拔不出来的半步状态。3.2 后果:节点丢失(内存泄漏)1 逐步追踪设 main 在断裂点被打断(head 还指向旧节点 n1),handler 完整插入新节点 nh(nh-nextn1, headnh),随后返回,main 继续执行head n——head 被改回n2。此时:nh 仍在堆上,却没有任何指针指向它——节点丢失,内存泄漏;同时链表数量与预期不符。2 可运行 demo:亲手制造一次重入破坏// reentrancy_demo.cpp —— 编译: g reentrancy_demo.cpp -o demo#includestdio.h#includestdlib.h#includesignal.h#includeunistd.htypedefstructnode{intval;structnode*next;}node_t;node_t*headNULL;voidhandler(intsig)// 第二条执行流:也往同一个链表头插{node_t*n(node_t*)malloc(sizeof(node_t));n-val-1;n-nexthead;headn;}intmain(void){signal(SIGALRM,handler);alarm(1);// 1 秒后,handler 打断 mainfor(inti0;i5000;i){// 插得足够慢,保证 alarm 落在插入区间内node_t*n(node_t*)malloc(sizeof(node_t));n-vali;n-nexthead;// ← 若 alarm 恰好在此刻打断 mainheadn;// ← handler 插入的节点将在这里被跳过usleep(500);// 500 微秒 × 5000 次 ≈ 2.5 秒}intcnt0;for(node_t*phead;p;pp-next)cnt;printf(expect5001 actual%d\n,cnt);// actual 5001 即发生了节点丢失return0;}多跑几次,actual常见为 5000:handler 插入的那个节点整个消失。工程上的修复方式是临界区阻塞信号(sigprocmask把 SIGALRM 挡住,插完再放行)——正是 block 语义的正面应用,呼应信号(三)。3.3 可重入与不可重入:判定标准1 不可重入的三类特征一个函数重入后会产生错误结果,基本逃不出三类原因(来源:man 7 signal-safety):维护静态数据:如标准 IO 库——“the stdio functions must maintain a statically allocated data buffer along with associated counters and indexes”,main 的 printf 打印到一半,handler 里再 printf,操作的是同一份缓冲区和索引,结果不可预测;调用 malloc/free:堆管理依赖全局的空闲块链表和锁,重入会破坏它;内部加锁或调用其他不安全函数:手册举例,glibc 的aio_suspend因内部使用pthread_mutex_lock而不是信号安全的。2 async-signal-safe:handler 里允许调用的函数表man 7 signal-safety 给出规范名称:async-signal-safe(异步信号安全)——“a function is async-signal-safe either because it is reentrant or because it is atomic with respect to signals”。POSIX 列表中的代表:_exit、write、read、open、close、kill、wait/waitpid、pipe、sigaction、sigprocmask、raise、time等。不在表里的,handler 一律不调——malloc、getpwnam、以及 stdio 全家(printf/scanf…)都在禁区。这也解释了 2.3 的 demo 为什么用 write 输出。3 大部分函数都不可重入动过全局状态、静态缓冲、堆管理的函数占了绝大多数——这是大部分函数都不可重入的原因。写 handler 时记住一条纪律:能不用库函数就不用,非用不可,查 signal-safety 列表;操作共享数据,先用 sa_mask/sigprocmask 阻塞相关信号。四、volatile:寄存器把内存藏起来了4.1 场景:main 轮询 flag,handler 置 11 demo 代码// volatile_demo.cpp —— 编译: g volatile_demo.cpp -O1 -o demo#includestdio.h#includesignal.h#includeunistd.hintflag0;// 故意先不加 volatilevoidhandler(intsig){flag1;}intmain(void){signal(SIGINT,handler);printf(pid%d, waiting CtrlC...\n,getpid());while(flag0);// 空转等待printf(正常退出\n);return0;}逻辑很直白:main 空转等 flag 变 1,handler 负责按铃。2 -O0:一切正常用g volatile_demo.cpp -O0 -o demo编译:按下 CtrlC,程序立刻打印正常退出。因为优化级别低,CPU 每次判断都老老实实去物理内存读 flag,handler 改内存,main 自然看得到。4.2 -O1 之后:死循环1 原因:编译器认为 flag不会变把-O0换成-O1,按下 CtrlC,程序继续空转,永远不退出,只能kill -9。原因:从编译器的视角,main函数与handler之间不存在直接调用关系,main 的代码路径上没有任何语句修改 flag——于是它做出一个合法的优化:把 flag 缓存到寄存器,循环判断只看寄存器,不再访存。2 寄存器覆盖了内存的真实情况此时 handler 明明把内存里的 flag 改成了 1,但 main 的判断走的是寄存器副本——那里永远是 0。一句话:寄存器覆盖了进程看到变量的真实情况,内存不可见了。验证方法:分别用 -O0 和 -O1 编译,objdump -d a.out | grep -A 15 main:对比循环体,-O1 版本里对 flag 的内存读取被搬出了循环。4.3 解法与边界1 volatile:每次都访存volatileintflag0;// 保证内存可见性:编译器不许把它缓存进寄存器加上volatile后,-O1 下也能正常退出。语义:告诉编译器该变量可能被程序控制流之外的因素修改(如信号处理函数、硬件),每次使用都必须真正访存。2 稍深一截:volatile 保证什么、不保证什么volatile只保证每次都读内存这一件事:不保证原子性,也不充当内存屏障。多线程场景用它防优化是常见误用,那是原子变量和锁的领域。信号场景的规范写法是volatile sig_atomic_t(APUE 建议的类型)——保证读写过程不可分割。面试里volatile 的作用如果能主动补上这句边界,是明显的加分项。五、收束:本篇三句话 三个实验5.1 本篇三句话sigaction sa_mask挡住 handler 重复递达,block 只管递达给用户进程,不管到达内核;可重入判定是 handler 操作共享数据的第一道纪律——大部分函数不可重入,handler 里只调 async-signal-safe 函数;volatile修的是编译器优化的副作用:寄存器副本覆盖内存真相,规范写法volatile sig_atomic_t。5.2 实验清单(三个 demo 都值得自己跑一遍)sa_mask_demo:3 秒内连按 5 次 CtrlC,数一数 handler 实际执行了几次;reentrancy_demo:观察expect5001 actual5000的节点丢失,再把 alarm 时间调短/加长对比;volatile_demo:-O0 与 -O1 各跑一次,再加 volatile 各跑一次,四个现象对齐本篇结论。六、文末面试题6.1 推导题(按本讲知识点,附答案)1.【推导】handler 执行期间,同一信号再次到达会怎样?答:不重入。当前信号被自动加入阻塞掩码,再次到达只在 pending 挂号;handler 返回、掩码恢复后补递一次。设SA_NODEFER可解除,默认关闭。2.【推导】block 能不能阻止信号到达内核?答:不能。block 只管内核 → 用户进程的递达环节;信号被记录到 pending(到达内核)不受 block 控制。被阻塞的信号照样挂号,只是暂不递达。3.【推导】为什么 handler 里不能调 malloc/free 或 printf?答:malloc 管理堆依赖全局的空闲块链表(及加锁),printf 依赖 stdio 的静态缓冲区与计数器——两条执行流同时进入会破坏其一致性,属于不可重入/非异步信号安全函数。handler 内输出用 write。4.【推导】volatile 保证了什么、没保证什么?答:保证每次访问都真实访存(内存可见性);不保证原子性、不充当内存屏障。信号标志位的规范类型是 volatile sig_atomic_t。6.2 真题(来源已核实,转述注明)1. volatile 的作用是什么?答:防止编译器把变量缓存进寄存器,保证每次访问都访存——即本篇 4.3 的内存可见性。【真题 · 转述自 知乎《腾讯后端面试题全记录:真实问题详细解析》 等多家大厂面经汇编(原题高频出现于腾讯/字节/美团后端面试,原文语境多为 Java 并发,语义与本篇 C/C 场景同源)】2. 信号处理函数中可以调用哪些函数?答:仅 async-signal-safe 列表内的函数(write/_exit/waitpid/sigaction 等),依据 man 7 signal-safety / APUE。【通用题库 · 依据 man 7 signal-safety 官方手册与 APUE;未检索到带公司名的该专题面经,按规范如实标注】小结:本篇解决了 handler 插队带来的三连问——重复抵达(sa_mask 挡住)、数据竞争(可重入判定挡住)、内存不可见(volatile 挡住)。三个 demo 都是自己机器上跑出来的才算数,评论区见实测结果。系列回顾:信号(一) · 信号(二) · 信号(三) · 信号(四) · 信号(五)下一篇预告:[信号(七)]——用户态与内核态从哪来?用户页表为什么人手一份、内核页表为什么逻辑上只有一份、CS 寄存器低两位(CPL)凭什么决定你是谁,最后用 SIGCHLD waitpid 收掉子进程,把信号的生命周期闭环。