【精通内核】CPU控制并发原理(五)
前言
📫作者简介: 小明java问道之路,专注于研究计算机底层/Java/Liunx 内核,就职于大型金融公司后端高级工程师,擅长交易领域的高安全/可用/并发/性能的架构设计📫
🏆 CSDN专家博主/Java优质创作者/CSDN内容合伙人、InfoQ签约作者、阿里云签约专家博主、华为云专家、51CTO专家/TOP红人 🏆
🔥如果此文还不错的话,还请👍关注、点赞、收藏三连支持👍一下博主~
本文导读
本文讲解CPU角度的中断控制,CPU层面并行并发和中断控制的原理,现代CPU的缓存结构和架构图、CPU缓存一致性的源码原理,以及CPU如何通过编译器的屏障与指令实现系统屏障,经过内联汇编代码验证之后,证明上述所说的 Linux 内核用 volatile 关键字实现系统屏障(指令重排),加深对系统屏障的内核源码和原理的理解。
一、CPU的系统屏障
由于我们的汇编代码是由编译器产生的,而我们的编译器是知道流水线的,因此编译器当然能够重排序汇编代码来更进一步的优化指令流水线,不仅是CPU可以乱序执行,而且我们的编译器也可以重排序代码,据此,这里的屏障就被分为了两种,即编译器屏障和指令级屏障。
1、编译器屏障
上述讨论到,CPU为了高效执行代码 引用了多级流水线,而我们的编译器也会面向CPU编译代码,所以也会导致指令重排序,我们先来看看以下代码和它的汇编代码。
代码声明了4个变量,即 a、b、c、d并初始化为 0, 然后在func_1函数体内修改a为1,并将d的值赋值给a,随后判断d是否为真(C语言非零即真), 如果为真,则输出c的值,同样 func_2
int a = 0, b = 0, c = 0, d = 0;
void func_1() {
a = 1;
b = d;
if (d) {
printf("%d", c);
}
};
void func_2() {
c = 1;
d = a;
if (a) {
printf("%d", b);
}
} ;
也是如此。我们来看看func 1未经优化的汇编代码。
func_1: // func_1 函数
push rbp // 保存 rbp
mov rbp, rsp // 将 rsp 的值 赋值给 rbp
mov DWORD PTR a[rip], 1 // 将 1 赋值给变量 a
mov eax, DWORD PTR d[rip] // 将 eax 中 d 赋值给 b
mov DWORD PTR b[rip], eax // 将 eax 中的 d 赋值 b
mov eax, DWORD PTR d[rip] // 将 d 放入 eax
test eax, eax // eax 值取and 看是否为0
je .L3
mov eax, DWORD PTR c[rip] // 将 c 放入 eax 通过 rip来做相对偏移寻址,等于 %rip+c
mov esi, eax
mov edi, OFFSET FLAT:.LC0 // 将 %d 的地址放入 edi
mov eax, 0 // 把调用好 0 方法 eax 中
call printf // 调用 printf 输出c
可以看到,未经优化编译器所生成的汇编代码是按照我们的代码书写顺序来执行的,那么如果发生重排序也是CPU的指令重排序,而不是编译器的,因为这里编译器编译的代码和我们的C代码的语义是一样的,同理func2的代码和func 1一样,这里就不粘贴出来了,只不过不同的是变量变为了c和d。那么我们来看看编译器优化过后的汇编代码(基于x86-64 gcc 12.2编译器)。
优化后编译出来的代码和我们写的代码有所不同。这就是编译器面向 CPU 编译,更合理地贴近于 CPU 的流水线架构了,不难发现当把 d 读取放入eax寄存器中时,由于赋值给a=1的指令和 b=d 的指令没有数据依赖,因此不如先把 d 读入,然后指令流水线就会在读入 d 时执行赋值操作
func_1: // func_1 函数
mov eax, DWORD PTR d[rip] // 将 eax 中放入 d
mov DWORD PTR a[rip], 1 // 将 1 赋值给变量 a
test eax, eax // eax 值取and 看是否为0
mov DWORD PTR b[rip], eax // 将 eax 中的 d 赋值 b
jne .L4
rep ret // 优化 CPU 分支预测器
有些人可能会认为这没什么关系,反正没有数据依赖,再排序就是为了快,最终结果没问题,但是你想想,如果有两个任务同时进入两个 CPU 中,任务A执行了 func_1方法,任务B执行了 func_2 方法会发生什么?
从我们的代码中可以看到我们的本意是,当d=1时,c 应该为1。但是想想如果发生了重排序,那么 d 的值会优先被加载而 a 的赋值操作却是在 d 之后,任务B同时执行 func_2 方法,同理也发生了重排序,即d的值先被赋值为a的值,然后c才等于1,这就造成了与我们预期结果不符的现象。。如何让编译器禁止这种优化呢?答案是采用编译器屏障
volatile int a = 0, b = 0, c = 0, d = 0;
void func_1() {
a = 1;
b = d;
if (d) {
printf("%d", c);
}
};
重新编译后,与我们的预期是一样的,先赋值 1,然后读入 d 赋值给 b ,确实起到了阻止编译器优化的作用。
接下来,我们来看看在 Linux 内核中是否可以用 volatile 关键字来实现阻止编译器优化呢?查看下列源码。
#define barrier()_asm__volatile_("":::"memory" )
可以看到就是通过内联汇编来做这个事的,核心代码是 "":::"memory" ,无汇编但是有 clobber 的指令,确切来说,它不包含汇编指令,所以不能叫作指令,只是起到提示编译器的作用。简而言之,这里的操作会影响内存,不可任意优化。下面来看看加 "":::"memory" 将是什么样的效果,先查看下列修改的代码。
volatile int a = 0, b = 0, c = 0, d = 0;
void func_1() {
a = 1;
_asm__volatile_("":::"memory" )
b = d;
if (d) {
printf("%d", c);
}
};
可以看到,加了 asm__volatile("":::"memory") 是相同的效果。 我们可以通过 volatile 关键字和内联汇编 asm__volatile("":::"memory") 提示编器,不可任意排序。
总结
本文讲解CPU角度的中断控制,CPU层面并行并发和中断控制的原理,现代CPU的缓存结构和架构图、CPU缓存一致性的源码原理,以及CPU如何通过编译器的屏障与指令实现系统屏障,经过内联汇编代码验证之后,证明上述所说的 Linux 内核用 volatile 关键字实现系统屏障(指令重排),加深对系统屏障的内核源码和原理的理解。
转载自:https://juejin.cn/post/7145858466073018382