本文转自 http://www.wowotech.net/kernel_synchronization/453.html。蜗窝出品,必属精品。
编译器(compiler)的工作就是优化我们的代码以提高性能。这包括在不改变程序行为的情况下重新排列指令。因为 compiler 不知道什么样的代码需要线程安全(thread-safe),所以 compiler 假设我们的代码都是单线程执行(single-threaded),并且进行指令重排优化并保证是单线程安全的。因此,当你不需要 compiler 重新排序指令的时候,你需要显式告诉 compiler,我不需要重排。否则,它可不会听你的。本篇文章中,我们一起探究 compiler 关于指令重排的优化规则。
注:测试使用 aarch64-linux-gnu-gcc 版本:7.3.0
编译器指令重排(Compiler Instruction Reordering)
compiler 的主要工作就是将对人们可读的源码转化成机器语言,机器语言就是对 CPU 可读的代码。因此,compiler 可以在背后做些不为人知的事情。我们考虑下面的 C语言代码:
int a, b;
void foo(void)
{
a = b + 1;
b = 0;
}
使用 aarch64-linux-gnu-gcc 在不优化代码的情况下编译上述代码,使用 objdump 工具查看 foo() 反汇编结果:
<foo>:
...
ldr w0, [x0] //load b to w0
add w1, w0, #0x1
...
str w1, [x0] //a = b + 1
...
str wzr, [x0] //b = 0
我们应该知道 Linux 默认编译优化选项是 -O2,因此我们采用 -O2 优化选项编译上述代码,并反汇编得到如下汇编结果:
<foo>:
...
ldr w2, [x0] //load b to w2
str wzr, [x0] //b = 0
add w0, w2, #0x1
str w0, [x1] //a = b + 1
比较优化和不优化的结果,我们可以发现:在不优化的情况下,a 和 b 的写入内存顺序符合代码顺序(program order);但是 -O2 优化后,a 和 b 的写入顺序和 program order 是相反的。-O2 优化后的代码转换成 C 语言可以看作如下形式:
int a, b;
void foo(void)
{
register int reg = b;
b = 0;
a = reg + 1;
}
这就是 compiler reordering(编译器重排)。为什么可以这么做呢?对于单线程来说,a 和 b 的写入顺序,compiler 认为没有任何问题。并且最终的结果也是正确的(a == 1 && b == 0)。这种 compiler reordering 在大部分情况下是没有问题的。但是在某些情况下可能会引入问题。例如我们使用一个全局变量 flag 标记共享数据 data 是否就绪。由于 compiler reordering,可能会引入问题。考虑下面的代码(无锁编程):
int flag, data;
void write_data(int value)
{
data = value;
flag = 1;
}
如果 compiler 产生的汇编代码是 flag 比 data 先写入内存,那么,即使是单核系统上,我们也会有问题。在 flag 置 1 之后,data 写 45 之前,系统发生抢占。另一个进程发现 flag 已经置 1,认为 data 的数据已经准备就绪。但是实际上读取 data 的值并不是 45。为什么 compiler 还会这么操作呢?因为,compiler 并不知道 data 和 flag 之间有严格的依赖关系。这种逻辑关系是我们人为强加的。我们如何避免这种优化呢?
显式编译器屏障(Explicit Compiler Barriers)
为了解决上述变量之间存在依赖关系导致 compiler 错误优化。compiler 为我们提供了编译器屏障(compiler barriers),可用来告诉 compiler 不要 reorder。我们继续使用上面的 foo() 函数作为演示实验,在代码之间插入 compiler barriers。
#define barrier() __asm__ __volatile__("": : :"memory")
int a, b;
void foo(void)
{
a = b + 1;
barrier();
b = 0;
}
barrier() 就是 compiler 提供的屏障,作用是告诉 compiler 内存中的值已经改变,之前对内存的缓存(缓存到寄存器)都需要抛弃,barrier() 之后的内存操作需要重新从内存 load,而不能使用之前寄存器缓存的值。并且可以防止 compiler 优化 barrier() 前后的内存访问顺序。barrier() 就像是代码中的一道不可逾越的屏障,barrier() 前的 load/store 操作不能跑到 barrier() 后面;同样,barrier() 后面的 load/store 操作不能在 barrier() 之前。依然使用 -O2 优化选项编译上述代码,反汇编得到如下结果:
<foo>:
...
ldr w2, [x0] //load b to w2
add w2, w2, #0x1
str w2, [x1] //a = a + 1
str wzr, [x0] //b = 0
...
我们可以看到插入 compiler barriers 之后,a 和 b 的写入顺序和 program order 一致。因此,当我们的代码中需要严格的内存顺序,就需要考虑 compiler barriers。
隐式编译器屏障(Implied Compiler Barriers)
除了显示的插入 compiler barriers 之外,还有别的方法阻止 compiler reordering。例如 CPU barriers 指令,同样会阻止 compiler reordering。后续我们再考虑 CPU barriers。除此以外,当某个函数内部包含 compiler barriers 时,该函数也会充当 compiler barriers 的作用。即使这个函数被 inline,也是这样。例如上面插入 barrier() 的 foo() 函数,当其他函数调用 foo() 时,foo() 就相当于 compiler barriers。考虑下面的代码:
int a, b, c;
void fun(void)
{
c = 2;
barrier();
}
void foo(void)
{
a = b + 1;
fun(); /* fun() call acts as compiler barriers */
b = 0;
}
fun() 函数包含 barrier(),因此 foo() 函数中 fun() 调用也表现出 compiler barriers 的作用,同样可以保证 a 和 b 的写入顺序。如果 fun() 函数不包含 barrier(),结果又会怎么样呢?实际上,大多数的函数调用都表现出 compiler barriers 的作用。但是,这不包含 inline 的函数。因此,fun() 如果被 inline 进 foo(),那么 fun() 就不具有 compiler barriers 的作用。如果被调用的函数是一个外部函数,其副作用会比 compiler barriers 还要强。因为 compiler 不知道函数的副作用是什么。它必须忘记它对内存所作的任何假设,即使这些假设对该函数可能是可见的。我们看一下下面的代码片段,printf() 一定是一个外部的函数。
int a, b;
void foo(void)
{
a = 5;
printf("smcdef");
b = a;
}
同样使用 -O2 优化选项编译代码,objdump 反汇编得到如下结果:
<foo>:
...
mov w2, #0x5 //#5
str w2, [x19] //a = 5
bl 640 <__printf_chk@plt> //printf()
ldr w1, [x19] //reload a to w1
...
str w1, [x0] //b = a
compiler 不能假设 printf() 不会使用或者修改 a 变量。因此在调用 printf() 之前会将 a 写 5,以保证 printf() 可能会用到新值。在 printf() 调用之后,重新从内存中 load a 的值,然后赋值给变量 b。重新 load a 的原因是 compiler 也不知道 printf() 会不会修改 a 的值。
因此,我们可以看到即使存在 compiler reordering,但是还是有很多限制。当我们需要考虑 compiler barriers 时,一定要显示的插入 barrier(),而不是依靠函数调用附加的隐式 compiler barriers。因为,谁也无法保证调用的函数不会被 compiler 优化成 inline 方式。
barrier() 除了防止编译乱序,还能做什么
barriers() 作用除了防止 compiler reordering 之外,还有什么妙用吗?我们考虑下面的代码片段:
int run = 1;
void foo(void)
{
while (run)
;
}
run 是个全局变量,foo() 在一个进程中执行,一直循环。我们期望的结果是 foo() 一直等到其他进程修改 run 的值为 0 才退出循环。实际 compiler 编译的代码和我们会达到我们预期的结果吗?我们看一下汇编代码:
0000000000000748 <foo>:
748: 90000080 adrp x0, 10000
74c: f947e800 ldr x0, [x0, #4048]
750: b9400000 ldr w0, [x0] //load run to w0
754: d503201f nop
758: 35000000 cbnz w0, 758 <foo+0x10> //if (w0) while (1);
75c: d65f03c0 ret
汇编代码可以转换成如下的 C 语言形式:
int run = 1;
void foo(void)
{
register int reg = run;
if (reg)
while (1)
;
}
compiler 首先将 run 加载到一个寄存器 reg 中,然后判断 reg 是否满足循环条件,如果满足就一直循环。但是循环过程中,寄存器 reg 的值并没有变化。因此,即使其他进程修改 run 的值为 0,也不能使 foo() 退出循环。很明显,这不是我们想要的结果。我们继续看一下加入 barrier() 后的结果:
0000000000000748 <foo>:
748: 90000080 adrp x0, 10000
74c: f947e800 ldr x0, [x0, #4048]
750: b9400001 ldr w1, [x0] //load run to w0
754: 34000061 cbz w1, 760 <foo+0x18>
758: b9400001 ldr w1, [x0] //load run to w0
75c: 35ffffe1 cbnz w1, 758 <foo+0x10> //if (w0) goto 758
760: d65f03c0 ret
可以看到加入 barrier() 后的结果真是我们想要的。每一次循环都会从内存中重新 load run 的值。因此,当有其他进程修改 run 的值为 0 的时候,foo() 可以正常退出循环。为什么加入 barrier() 后的汇编代码就是正确的呢?因为 barrier() 作用是告诉 compiler 内存中的值已经变化,后面的操作都需要重新从内存 load,而不能使用寄存器缓存的值。因此,这里的 run 变量会从内存重新 load,然后判断循环条件。这样,其他进程修改 run 变量,foo() 就可以看得见了。
在 Linux kernel 中,提供了 cpu_relax() 函数,该函数在 ARM64 平台定义如下:
static inline void cpu_relax(void)
{
asm volatile("yield" ::: "memory");
}
我们可以看出,cpu_relax() 是在 barrier() 的基础上又插入一条汇编指令 yield。在 kernel 中,我们经常会看到一些类似上面举例的 while 循环,循环条件是个全局变量。为了避免上述所说问题,我们就会在循环中插入 cpu_relax() 调用。
int run = 1;
void foo(void)
{
while (run)
cpu_relax();
}
当然也可以使用 Linux 提供的 READ_ONCE()。例如,下面的修改也同样可以达到我们预期的效果。
int run = 1;
void foo(void)
{
while (READ_ONCE(run)) /* similar to while (*(volatile int *)&run) */
;
}
当然你也可以修改 run 的定义为 volatile int run,
就会得到如下代码。同样可以达到预期目的。
volatile int run = 1;
void foo(void)
{
while (run)
;
}