
📚 本文收录于「流浪」的系列专栏
| 🐧 Linux系统 | ⚙️ C++ |
| 📊 数据结构与算法 | 🐍 Python |
| 🔗 LangChain & LangGraph | 🗄️ MySQL 数据库 |
| 🌿 Git 工具 | 🌐 计算机网络 |
| 🤖 AI | 💯 大厂面试、八股 |
| 📚 学习筑基专栏 |
🏠 博客主页:流浪 | 📝 原创首发于 CSDN
前情 :信号(五)讲透了捕捉全流程------do_signal 查账、四次用户态/内核态切换,并埋下一条线:handler 会"插在 main 任意两条指令之间"执行。
本篇 兑现这条线:handler 插队之后,会出什么乱子、又该怎么防。三道防线依次是------sa_mask(防重复递达)、可重入判定(防共享数据被破坏)、volatile(防内存不可见)。这是信号六与信号七两篇中的前篇。
一、重新认识 sigaction
信号(五)里注册 handler 用的是 signal(),一句话带过。本篇补上它的完整形态:sigaction。
1.1 三个参数:捕捉什么、怎么处理、旧的输出型
1 函数原型
c
// 来源:man 2 sigaction
int sigaction(int signum,
const struct sigaction *_Nullable restrict act,
struct sigaction *_Nullable oldact);
signum:要捕捉的信号,除 SIGKILL、SIGSTOP 外任意有效信号;act:非 NULL 时,用它安装新动作;oldact:非 NULL 时,先前的处理动作被保存到这里。
2 第二个参数:告诉内核"怎么处理"
第二个参数是一个 struct sigaction,把处理动作的全部细节 一次说清(见 1.2)。对比 signal(2, handler) 这种一句话注册,sigaction 把"处理期间屏蔽谁、用什么标志、按哪种函数原型回调"全部开放给调用者。

3 第三个参数:oldact,输出型
oldact 是典型的输出型参数 :把该信号"上一次"的处理动作带出来。典型用法是临时接管信号、处理完再恢复:

c
struct sigaction act, oldact;
act.sa_handler = my_handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGINT, &act, &oldact); // 安装新动作,旧动作存入 oldact
/* ... 临时接管期间 ... */
sigaction(SIGINT, &oldact, NULL); // 恢复旧动作
1.2 struct sigaction:处理动作的完整描述
1 字段一览
c
// 来源:man 2 sigaction(经 glibc 封装后的用户可见结构)
struct sigaction {
void (*sa_handler)(int); // 处理函数(简单形式)
void (*sa_sigaction)(int, siginfo_t *, void *); // 处理函数(带信息形式)
sigset_t sa_mask; // 处理期间额外阻塞的信号集
int sa_flags; // 行为标志,按位或
void (*sa_restorer)(void); // 不供应用程序使用
};
2 测试demo
c
#include <iostream>
#include <signal.h>
void handler(int signum)
{
std::cout << "signum:" << signum << std::endl;
while (true)
{
sigset_t pending;
int n = sigpending(&pending);
for (int i = 31; i > 0; i--)
{
if (sigismember(&pending, i))
{
std::cout<<"1";
}
else
std::cout<<"0";
}
sleep(1);
std::cout<<std::endl;
}
}
int main()
{
struct sigaction oct,od;
oct.sa_handler=handler;
sigemptyset(&oct.sa_mask);
oct.sa_flags = 0;
sigaction(SIGINT, &oct, &od); // 对2号信号进行了捕捉, 2,3,4都屏蔽
while(true)
{
std::cout << "hello world: " << getpid() << std::endl;
sleep(1);
}
return 0;
}

3 sa_handler 与 sa_sigaction 是互斥的两选一
手册明确:某些架构上这两个成员是联合体,不要同时赋值 。默认用 sa_handler(int);在 sa_flags 里指定 SA_SIGINFO 时,改用 sa_sigaction(信号, siginfo_t*, 上下文)------能拿到"信号是谁发的、从哪发的"这类附加信息,本篇不展开。

4 sa_flags 与 sa_restorer
sa_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 执行中,再按一次 Ctrl+C
1 直觉上的担忧
考虑这样的代码:对 2 号信号(SIGINT,即 Ctrl+C)注册 handler,而 handler 本身执行很久。如果在 handler 执行到一半时,用户再按一次 Ctrl+C,会发生什么?直觉的回答是"嵌套":handler 里再进一个 handler,不断递归,耗尽栈和内存。
2 实际行为:第二次 Ctrl+C 只"挂号",不执行
实际不会递归。第二次 Ctrl+C 到达内核后,只是在 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 blocked during execution of the signal handler "。即 handler 执行期间,sa_mask 里指定的信号一并被阻塞,返回后一并恢复。典型用途:handler 要操作一块全局数据时,把"可能抢这块数据的其他信号"临时挡在门外。

2 测试demo
c
#include <iostream>
#include <signal.h>
void handler(int signum)
{
std::cout << "signum:" << signum << std::endl;
while (true)
{
sigset_t pending;
int n = sigpending(&pending);
for (int i = 31; i > 0; i--)
{
if (sigismember(&pending, i))
{
std::cout << "1";
}
else
std::cout << "0";
}
sleep(1);
std::cout << std::endl;
}
}
int main()
{
struct sigaction oct, od;
oct.sa_handler = handler;
sigemptyset(&oct.sa_mask);
sigaddset(&oct.sa_mask, 3);
sigaddset(&oct.sa_mask, 4);
oct.sa_flags = 0;
sigaction(SIGINT, &oct, &od); // 对2号信号进行了捕捉
while (true)
{
std::cout << "hello world: " << getpid() << std::endl;
sleep(1);
}
return 0;
}

2 可运行 demo:亲手验证"不重入"
c
// sa_mask_demo.cpp ------ 编译: g++ sa_mask_demo.cpp -o demo
#include <signal.h>
#include <unistd.h>
void handler(int sig)
{
write(STDOUT_FILENO, "handler 进入\n", 15);
sleep(3); // 人为拖长,留出连按 Ctrl+C 的时间
write(STDOUT_FILENO, "handler 退出\n", 15);
}
int main(void)
{
struct sigaction act;
act.sa_handler = handler;
sigemptyset(&act.sa_mask); // 不额外追加阻塞(当前信号仍会被自动阻塞)
act.sa_flags = 0;
sigaction(SIGINT, &act, NULL);
while (1)
pause();
return 0;
}

注意 handler 里用的是 write 而不是 printf------这不是随意选择,原因见第三章。运行后在 3 秒内连按五次 Ctrl+C ,观察输出:进入/退出 严格交替,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 流都在 insert
sa_mask 解决的是"同一个 handler 嵌套自己"。但 handler 的更大风险在于:它插在 main 的任意两条指令之间 执行。设 main 正在向一条全局链表插入节点,handler 恰好在这时递达,而 handler 里也在往同一条链表 插入------同一个 insert,被两条执行流先后进入,这就是函数被重入 。

2 断裂点:两次赋值之间
头插法只有两步:
c
node_t *n = malloc(sizeof(node_t));
n->val = x;
n->next = head; // ← 断裂点:执行完这行,若信号递达
head = n; // ← 这行还没执行
n->next = head 之后、head = n 之前,是插不进去也拔不出来的"半步状态"。
3.2 后果:节点丢失(内存泄漏)
1 逐步追踪
设 main 在断裂点被打断(head 还指向旧节点 n1),handler 完整插入新节点 nh(nh->next=n1, head=nh),随后返回,main 继续执行 head = n------head 被改回 n2。此时:nh 仍在堆上,却没有任何指针指向它------节点丢失,内存泄漏;同时链表数量与预期不符。
2 可运行 demo:亲手制造一次重入破坏
c
// reentrancy_demo.cpp ------ 编译: g++ reentrancy_demo.cpp -o demo
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
typedef struct node { int val; struct node *next; } node_t;
node_t *head = NULL;
void handler(int sig) // 第二条执行流:也往同一个链表头插
{
node_t *n = (node_t *)malloc(sizeof(node_t));
n->val = -1;
n->next = head;
head = n;
}
int main(void)
{
signal(SIGALRM, handler);
alarm(1); // 1 秒后,handler 打断 main
for (int i = 0; i < 5000; i++) { // 插得足够慢,保证 alarm 落在插入区间内
node_t *n = (node_t *)malloc(sizeof(node_t));
n->val = i;
n->next = head; // ← 若 alarm 恰好在此刻打断 main
head = n; // ← handler 插入的节点将在这里被"跳过"
usleep(500); // 500 微秒 × 5000 次 ≈ 2.5 秒
}
int cnt = 0;
for (node_t *p = head; p; p = p->next) cnt++;
printf("expect=5001 actual=%d\n", cnt); // actual < 5001 即发生了节点丢失
return 0;
}

多跑几次,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 置 1
1 demo 代码
c
// volatile_demo.cpp ------ 编译: g++ volatile_demo.cpp -O1 -o demo
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int flag = 0; // 故意先不加 volatile
void handler(int sig) { flag = 1; }
int main(void)
{
signal(SIGINT, handler);
printf("pid=%d, waiting Ctrl+C...\n", getpid());
while (flag == 0)
; // 空转等待
printf("正常退出\n");
return 0;
}

逻辑很直白:main 空转等 flag 变 1,handler 负责"按铃"。
2 -O0:一切正常
用 g++ volatile_demo.cpp -O0 -o demo 编译:按下 Ctrl+C,程序立刻打印"正常退出"。因为优化级别低,CPU 每次判断都老老实实去物理内存读 flag,handler 改内存,main 自然看得到。
4.2 -O1 之后:死循环
1 原因:编译器认为 flag"不会变"
把 -O0 换成 -O1,按下 Ctrl+C,程序继续空转,永远不退出 ,只能 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:每次都访存
c
volatile int flag = 0; // 保证内存可见性:编译器不许把它缓存进寄存器
加上 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 次 Ctrl+C,数一数 handler 实际执行了几次;reentrancy_demo:观察expect=5001 actual=5000的节点丢失,再把 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 收掉子进程,把信号的生命周期闭环。