【Linux信号】Linux进程信号(下):可重入函数、Volatile关键字、SIGCHLD信号

🎬 个人主页艾莉丝努力练剑
专栏传送门 :《C语言》《数据结构与算法》《C/C++干货分享&学习过程记录
Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享

⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平


🎬 艾莉丝的简介:


文章目录

  • [5 ~> 可重入函数](#5 ~> 可重入函数)
    • [5.1 为什么会发生内核态与用户态的切换?](#5.1 为什么会发生内核态与用户态的切换?)
    • [5.2 结合图示,为什么 insert 是不可重入的?](#5.2 结合图示,为什么 insert 是不可重入的?)
    • [5.3 如何判断可重入和不可重入?](#5.3 如何判断可重入和不可重入?)
    • [5.4 场景应用的区别](#5.4 场景应用的区别)
  • [6 ~> Volatile](#6 ~> Volatile)
    • [6.1 核心概念:什么是 volatile?](#6.1 核心概念:什么是 volatile?)
    • [6.2 现象分析:消失的信号响应](#6.2 现象分析:消失的信号响应)
    • [6.3 根源探究:CPU 寄存器与内存的"可见性"冲突](#6.3 根源探究:CPU 寄存器与内存的“可见性”冲突)
    • [6.4 解决方案:volatile 的作用](#6.4 解决方案:volatile 的作用)
      • [6.4.1 汇编伪代码对比](#6.4.1 汇编伪代码对比)
    • [6.5 总结与对比](#6.5 总结与对比)
    • [6.6 有无 volatile 关键字在生成的真实汇编代码上的具体差异](#6.6 有无 volatile 关键字在生成的真实汇编代码上的具体差异)
      • [6.6.1 无 volatile 且开启优化 (gcc -O1)](#6.6.1 无 volatile 且开启优化 (gcc -O1))
      • [6.6.2 有 volatile 且开启优化 (gcc -O1)](#6.6.2 有 volatile 且开启优化 (gcc -O1))
      • [6.6.3 思考:为什么不默认给所有变量加 volatile?](#6.6.3 思考:为什么不默认给所有变量加 volatile?)
    • [6.7 思维导图](#6.7 思维导图)
  • [7 ~> SIGCHLD信号](#7 ~> SIGCHLD信号)
    • [7.1 SIGCHLD:子进程的"最后通牒"](#7.1 SIGCHLD:子进程的“最后通牒”)
    • [7.2 为什么要处理 SIGCHLD?(僵尸进程问题)](#7.2 为什么要处理 SIGCHLD?(僵尸进程问题))
    • [7.3 实战演练:捕获 SIGCHLD 证明](#7.3 实战演练:捕获 SIGCHLD 证明)
    • [7.4 如何正确地回收子进程?](#7.4 如何正确地回收子进程?)
    • [7.5 最省事的方案:SIG_IGN](#7.5 最省事的方案:SIG_IGN)
    • [7.6 子进程回收的三种境界](#7.6 子进程回收的三种境界)
    • [7.7 非阻塞等待演示](#7.7 非阻塞等待演示)
    • [7.8 思维导图](#7.8 思维导图)
  • 结尾

5 ~> 可重入函数

下图展示了 不可重入函数(Non-reentrant function) 在多任务或信号处理环境下的"灾难现场"。

  • 不可重入性通常是因为函数操作了全局变量或静态变量,导致并发执行时数据状态发生冲突。

为什么"时钟中断"和"内核态切换"会让这个问题无处不在?

5.1 为什么会发生内核态与用户态的切换?

进程运行过程中,时钟中断(Clock Interrupt 是硬件强制触发的。

  • 触发机制:为了实现多任务公平调度,操作系统会给每个进程分配时间片。当时钟中断发生时,CPU 强制进入内核态

  • 信号检查:当内核处理完中断,准备从内核态返回用户态的前一刻,它会检查当前进程是否有未处理的信号(Signal)。

  • 执行处理:如果有信号,内核会跳转去执行用户定义的信号处理函数(如图中的sighandler)。

这就是风险点 :这种切换可以在用户程序的任何一行代码执行完后发生,甚至是赋值语句的一半。

5.2 结合图示,为什么 insert 是不可重入的?

图中展示的是典型的"链表破坏"案例。我们来复盘一下这个过程:

  • 1、初始状态 (0)head 指向链表。

  • 2、第一次执行 (1)main 调用 insert(&node1)。第一步完成:node1->next = head。此时 head 还没更新,中断发生了!

  • 3、嵌套调用 (2-3) :在信号处理函数中,又调用了 insert(&node2)。它顺利跑完了两步,把 node2 插入了链表,此时 head 指向了 node2

  • 4、返回现场 (4) :信号处理结束,回到 maininsert 继续跑。它执行了被中断的那一步:head = p(这里的 pnode1)。

  • 5、结果head 被改回了 node1node2 就这样莫名其妙地从链表中消失了(内存泄漏且逻辑错误)

5.3 如何判断可重入和不可重入?

可以参考下面这个对比表来快速判断:

5.4 场景应用的区别

可重入还是不可重入都没有绝对的好坏,只有场景的适配:

  • 不可重入函数:编写简单,适合单线程、不涉及异步信号、对性能要求极高(避免栈开销)的底层代码。

  • 可重入函数 :是线程安全(Thread-safe) 的基石。在编写多线程程序、实时系统(RTOS)或信号处理程序时,必须保证函数是可重入的。

核心原则:sighandler(信号处理函数)内部,永远只调用"Async-signal-safe(异步信号安全) "的函数。像 printf 内部维护了全局缓冲区,在信号处理里用它其实是很危险的!


6 ~> Volatile

volatile关键字的核心作用:确保内存变量的"可见性",抑制编译器的过度优化。

6.1 核心概念:什么是 volatile?

volatile的字面意思是"易变的"。在编程中,它告诉编译器:这个变量可能会在当前代码流之外被修改(例如:由信号处理函数、多线程、或者硬件中断修改)。

因此,编译器在编译时严禁 对该变量进行任何"假定不变"的优化,每次使用它时必须老老实实地去内存地址中读取。

6.2 现象分析:消失的信号响应

上图中通过一个典型的while(!flag)循环展示了优化带来的问题:

  • 代码意图: main 函数进入死循环等待 flag 变为 1;当用户按下 Ctrl + C 触发信号,执行 handler 将 flag 改为 1,理论上 main 应该退出循环。

  • 非优化状态(-O0): 程序运行符合预期,信号触发后进程正常退出。

  • 开启优化(如 -O1 及以上):

    • 异常现象 :即便执行了 handler 并打印了"更改 flag 0->1",main 函数的循环依然停不下来。

    • 原因 :编译器认为在 main 函数的执行流中没有修改 flag 的语句,为了提高效率,它将 flag 的值缓存到了 CPU 寄存器中

6.3 根源探究:CPU 寄存器与内存的"可见性"冲突

图片中的原理图揭示了优化的底层逻辑:

  • 编译器的推断 :编译器扫描 main 函数,发现从循环开始到结束,没有任何逻辑会改变 flag

  • 寄存器缓存:CPU 访问寄存器的速度远快于内存。为了追求极致性能,编译器生成的汇编代码只在循环开始前将内存中的 flag 载入寄存器(如 eax),后续的 while 判断全部基于寄存器中的旧值。

  • 逻辑断层 :当信号发生时,内核触发的 handler 修改的是内存 里的 flag。但此时 main 函数只盯着自己的寄存器看,完全不知道内存里的值已经变了。

这就是"内存不可见性": 内存的改变对正在高速运行的 CPU 寄存器副本无效。

6.4 解决方案:volatile 的作用

当给变量加上 volatile 修饰(volatile int flag = 0;)后,情况发生了根本变化:

  • 抑制优化 :强制要求编译器每次执行 while 判断时,必须重新生成一条 mov 指令,从内存地址读取最新值。

  • 保持可见性 :确保了异步执行流(如信号处理)对变量的修改,能即时被当前执行流(main)察觉。

6.4.1 汇编伪代码对比

6.5 总结与对比

6.6 有无 volatile 关键字在生成的真实汇编代码上的具体差异

下面我们演示如何使用 gcc -S 命令来查看开启优化后,有无 volatile 关键字在生成的真实汇编代码上的具体差异。

我们直接通过对比真实的汇编逻辑来看一下 volatile 是如何"保命"的。

假设我们有这样一段核心代码:

c 复制代码
volatile int flag = 0; // 或者去掉 volatile
while (!flag);

6.6.1 无 volatile 且开启优化 (gcc -O1)

编译器为了效率,会认为在 main 函数的逻辑里,没有谁会改变 flag。于是它生成的汇编逻辑类似于这样:

bash 复制代码
# 优化后的伪汇编 (无 volatile)
mov eax, [flag]        # 1. 仅在循环开始前,将内存中的 flag 读入寄存器 eax
test eax, eax          # 2. 测试 eax 是否为 0
jnz label_end          # 3. 如果不为 0,跳出循环
label_loop:            # --- 循环体开始 ---
    jmp label_loop     # 4. 关键点:直接原地跳转!不再去检查内存了
label_end:

结果: 此时即便信号处理函数修改了内存里的 flag,CPU 的执行流也已经在 label_loop 那里"跑飞"了,它只会无限循环,永远看不到内存的变化。

6.6.2 有 volatile 且开启优化 (gcc -O1)

加上 volatile 后,编译器收到了明确指令:不要自作聪明。

bash 复制代码
# 优化后的伪汇编 (有 volatile)
label_loop:            # --- 循环体开始 ---
    mov eax, [flag]    # 1. 强制:每次循环都必须从内存地址 [flag] 读取
    test eax, eax      # 2. 测试最新的值
    jz label_loop      # 3. 如果还是 0,继续回到循环起点重新读取内存
label_end:

结果 : 每次判断都会有一次真实的内存访问。一旦 handler 修改了内存,下一次循环迭代就能立刻发现 flag 变成了 1,从而正常退出。

6.6.3 思考:为什么不默认给所有变量加 volatile?

6.7 思维导图


7 ~> SIGCHLD信号

Linux 信号处理中两个最重要的坑:不可重入函数(逻辑安全)和 volatile(内 存可见性),我们已经了解过啦。

接下来我们来看SIGCHLD信号。

我们主要讨论一下:SIGCHLD 信号的处理机制以及如何优雅地解决"僵尸进程"回收问题。

7.1 SIGCHLD:子进程的"最后通牒"

问题:子进程退出是安安静静的退出嘛?

  • Linux 进程管理中的一个重要机制:子进程退出并非"安安静静",而是一次显式的"打招呼"。

默认是忽略的。

问题:子进程退出是安安静静的退出嘛。

不是,子进程退出,会给父进程发送SIGCHID信号。

我们来看看证明:

pause就是等待一个信号。

父进程获得了一个这个信号!

以前没感觉是没学信号。正因为我们有了这个认知。

正如之前验证的,子进程退出并非无声无息。

  • 信号编号:17 号信号。

  • 触发机制 :子进程终止 (Termination)、停止 (Stopped)或恢复(Continued)时,内核都会向父进程发送该信号。

  • 默认动作:忽略(Ign)。

7.2 为什么要处理 SIGCHLD?(僵尸进程问题)

在多进程服务器中,父进程通常在忙于处理业务逻辑(如 while(1) 循环),无法预知子进程何时退出。

  • 1、如果不回收 :子进程变成僵尸进程(Zombie),占用进程表项。

  • 2、常规阻塞等待 :如果在主逻辑调用 wait(),父进程会被阻塞,无法处理新请求。

  • 3、解决方案 :利用信号处理机制 。当 SIGCHLD 到达时,父进程异步跳转到处理函数中执行 wait,实现"随死随埋"。

我们以前等待子进程是父进程主动去等,那我们现在等待子进程方式要不要发生一些改变,父进程自己忙自己的事,不退出就行,当父进程收到信号,在信号处理函数内部,回收子进程?

7.3 实战演练:捕获 SIGCHLD 证明

图片中的实验逻辑清晰地证明了信号的传递:

cpp 复制代码
void handler(int signo) {
    printf("pid: %d get a signal: %d\n", getpid(), signo);
}

int main() {
    signal(SIGCHLD, handler); // 注册信号
    if (fork() == 0) {
        printf("我是子进程, pid: %d\n", getpid());
        sleep(5);
        exit(0); // 5秒后退出,触发信号
    }
    while(1) pause(); // 父进程挂起等待信号
}

实验现象 :子进程退出瞬间,父进程打印 signal: 17,证明信号确实发出了。

7.4 如何正确地回收子进程?

在处理 SIGCHLD 时,有两个关键坑点需要注意:

1、坑点:信号不排队

现象:如果有 10 个子进程同时退出,内核可能只向父进程发送一次 SIGCHLD(信号被覆盖)。

对策:在 handler 中使用 while 循环进行回收。

2、坑点:阻塞问题

现象:如果使用 wait(),在处理函数内部可能会导致不必要的阻塞。

对策:使用 waitpid(-1, NULL, WNOHANG)WNOHANG 表示非阻塞等待,没死掉的进程直接跳过,死掉的立刻回收。

7.5 最省事的方案:SIG_IGN

c 复制代码
signal(SIGCHLD, SIG_IGN);
  • 特殊性 :在 Linux 中,如果父进程显式地将 SIGCHLD 的处理动作设为 SIG_IGN(忽略),那么子进程在退出时会直接被内核回收,不会产生僵尸进程

  • 优点 :代码最简,无需写处理函数,无需手动调用 wait

7.6 子进程回收的三种境界

7.7 非阻塞等待演示

这样会不会有问题?

如果有多个子进程呢?比如说10个,这个代码还能正常工作嘛?

咋还有没被回收的呢?我们之前讲过的,我们在一段时间快速的处理同一个信号,正在处理一个的时候,这个信号是pending下一个的,被阻塞的。位图只能保存最新的一个,信号一直在被丢弃,所以不可能全回收的,每次调用函数只回收一个。

要解决这个问题,必须把 "被动等待信号" 改为 "主动循环扫荡"

标准的写作模板应该是:

  • handler 内部使用 while 循环,配合 WNOHANG(非阻塞)参数。
c 复制代码
void handler(int signo) {
    // 关键:循环回收,直到没有已经退出的子进程为止
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        printf("成功回收了一个子进程\n");
    }
}
  • 为什么用 while? 确保一次信号通知,就能把当前所有已死的子进程"一网打尽",弥补信号丢失的问题。

  • 为什么用 WNOHANG? 防止 handler 陷入阻塞。如果没有子进程退出了,waitpid 会立刻返回 0,让父进程回到主逻辑。

    上图这样,改了一下,但是这样做也是不对的,因为不是所有子进程都会退出,那这个信号捕捉函数就在那等,不就死循环了嘛,也回不到父进程了。这个测试出来可以是我这个案例基本上都会退的,但是这个逻辑不太对了。

像这样:

退几个回收几个,没有或者不退出的就直接break了------这样才是比较合理的。


SIGCHLD + while + 非阻塞:对任意个数子进程退出进行回收。

父进程必须得等待子进程嘛? ------之前我们说的是必须的------但是其实是大部分情况下是必须的!

回收僵尸,再就是可以获取退出信号和退出码(这个是可以选或者不选的,看你需不需要)。

特殊情况:在Linux系统中,系统允许父进程在一定场景下可以不用等待子进程退出!


也没存在僵尸进程,所以等不等都是可以的,不是说本来就是忽略的吗,为啥还要设置呢?这是为什么? -- 默认的是在内核层面上面进行忽略,我们设置的是用户级的就这样理解吧,两个是不同的忽略就可以了。

先有硬件中断的,但是硬件中断是操作系统来响应外部的,我们OS也需要一种内部的对某种事件进行处理的技术,所以模拟硬件中断设计的信号机制。信号是模拟的硬件中断的,所以是两个完全不一样的东西,但是在思想上其实是一致的,信号是后来者,完成异步机制和通知,是在模拟硬件中断。

信号编号就类似中断向量表的下标(中断号)。

硬件上叫中断,软件上叫信号。

为什么我们在讲信号时要加上中断,因为本来信号就跟中断思路上比较像一些。

7.8 思维导图


结尾

uu们,本文的内容到这里就全部结束了,艾莉丝在这里再次感谢您的阅读!

结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!

往期回顾

【Linux信号】Linux进程信号(中):信号保存、信号处理(含"OS是如何运行的?")

🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡 ૮₍ ˶ ˊ ᴥ ˋ˶₎ა

相关推荐
Lugas Luo2 小时前
Kernel 5.10 SD卡专属探测、上电与注册流程分析 (Detect -> Power Up -> Add)
linux·嵌入式硬件
和小潘一起学AI2 小时前
AI面试问答
人工智能
深蓝海拓2 小时前
西门子S7-1500PLC的常用Area地址以及网络读写
笔记·学习·plc
liuyao_xianhui2 小时前
优选算法_锯齿形层序遍历二叉树_队列_C++
java·开发语言·数据结构·c++·算法·链表
si莉亚2 小时前
2026.3.31成功安装Ubuntu22.04+ROS2记录
linux·c++·开源
常利兵2 小时前
Spring Boot 实现网络限速:让流量“收放自如”
网络·spring boot·后端
上海云盾安全满满2 小时前
服务器很卡,是CC攻击造成的吗
运维·服务器·网络
RrEeSsEeTt2 小时前
【HackTheBox】- Monteverde 靶机学习
linux·网络安全·渗透测试·kali·红队·hackthebox·ad域
AnalogElectronic2 小时前
uniapp学习3,简易记事本
学习·uni-app