
🎬 个人主页 :艾莉丝努力练剑
❄专栏传送门 :《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) :信号处理结束,回到
main的insert继续跑。它执行了被中断的那一步:head = p(这里的p是node1)。 -
5、结果 :
head被改回了node1。node2就这样莫名其妙地从链表中消失了(内存泄漏且逻辑错误)。
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是如何运行的?")
🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡 ૮₍ ˶ ˊ ᴥ ˋ˶₎ა
