Linux系统篇34——信号(六):信号处理期间又来了怎么办?——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 函数原型
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_NODEFERSA_SIGINFOSA_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):

  1. 维护静态数据:如标准 IO 库------"the stdio functions must maintain a statically allocated data buffer along with associated counters and indexes",main 的 printf 打印到一半,handler 里再 printf,操作的是同一份缓冲区和索引,结果不可预测;
  2. 调用 malloc/free:堆管理依赖全局的空闲块链表和锁,重入会破坏它;
  3. 内部加锁或调用其他不安全函数 :手册举例,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 列表中的代表:_exitwritereadopenclosekillwait/waitpidpipesigactionsigprocmaskraisetime 等。不在表里的,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 本篇三句话

  1. sigaction + sa_mask 挡住 handler 重复递达,block 只管"递达给用户进程",不管"到达内核";
  2. 可重入判定 是 handler 操作共享数据的第一道纪律------大部分函数不可重入,handler 里只调 async-signal-safe 函数;
  3. volatile 修的是编译器优化的副作用:寄存器副本覆盖内存真相,规范写法 volatile sig_atomic_t

5.2 实验清单(三个 demo 都值得自己跑一遍)

  1. sa_mask_demo:3 秒内连按 5 次 Ctrl+C,数一数 handler 实际执行了几次;
  2. reentrancy_demo:观察 expect=5001 actual=5000 的节点丢失,再把 alarm 时间调短/加长对比;
  3. 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 收掉子进程,把信号的生命周期闭环。

相关推荐
学烹饪的小胡桃1 小时前
资产设备管理系统 WGFIX 怎么设置https访问
linux·运维·服务器·网络·安全
ssshooter1 小时前
Node.js 天生就是异步的,为什么还要消息队列?
javascript·后端·面试
邪修king2 小时前
Re:Linux系统篇(二十六):文件系统(二):Ext 文件系统底层详解:从 inode、块组到软硬链接,结合 Windows 讲透文件管理本质
android·java·linux
高亦真2 小时前
今天是学习嵌入式的第38天
linux·学习·算法
志栋智能2 小时前
超自动化巡检如何生成“有灵魂”的运维报告?
大数据·运维·人工智能·架构·自动化
凌风的跨境分享2 小时前
Temu运营避坑指南:制造地点信息填写合规要点与批量优化方法
运维·服务器·人工智能
DYWorker0012 小时前
Linux驱动子系统:中断子系统 —— Consumer和Provider(005)
linux·驱动开发
草莓熊Lotso2 小时前
【Redis 进阶】主从复制深度解析:从配置落地到 PSYNC 同步原理
linux·开发语言·网络·数据库·redis·缓存·php
金虹桥程序员2 小时前
【动画】 📚 CSS 动画与 GPU 合成层优化全链路指南
前端·面试