linux 乱序,编译乱序(Compiler Reordering)

本文深入探讨了编译器乱序(Compiler Reordering)的现象,解释了为何编译器会在不改变程序行为的前提下重排指令。通过示例展示了在不同优化级别下,编译器如何调整代码顺序,以及如何通过显式插入编译器屏障(Compiler Barriers)来防止指令重排。此外,还讨论了隐式编译器屏障,如函数调用的屏障效果,并提到了CPU执行乱序的问题,强调了在特定场景下使用内存屏障的必要性。
摘要由CSDN通过智能技术生成

编译乱序(Compiler Reordering)

作者:smcdef 发布于:2019-1-23 22:59

分类:内核同步机制

编译乱序

编译乱序(Compiler Reordering)

编译器(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()反汇编结果:

:

...

ldrw0, [x0]// load b to w0

addw1, w0, #0x1

...

strw1, [x0]// a = b + 1

...

strwzr, [x0]// b = 0

我们应该知道Linux默认编译优化选项是-O2,因此我们采用-O2优化选项编译上述代码,并反汇编得到如下汇编结果:

:

...

ldrw2, [x0]// load b to w2

strwzr, [x0]// b = 0

addw0, w2, #0x1

strw0, [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优化选项编译上述代码,反汇编得到如下结果:

:

...

ldrw2, [x0]// load b to w2

addw2, w2, #0x1

strw2, [x1]// a = a + 1

strwzr, [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 act 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反汇编得到如下结果。

:

...

movw2, #0x5// #5

strw2, [x19]// a = 5

bl640 <__printf_chk>// printf()

ldrw1, [x19]// reload a to w1

...

strw1, [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 :

748:90000080 adrpx0, 10000

74c:f947e800 ldrx0, [x0, #4048]

750:b9400000 ldrw0, [x0]// load run to w0

754:d503201f nop

758:35000000 cbnzw0, 758 // 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 :

748:90000080 adrpx0, 10000

74c:f947e800 ldrx0, [x0, #4048]

750:b9400001 ldrw1, [x0]// load run to w0

754:34000061 cbzw1, 760

758:b9400001 ldrw1, [x0]// load run to w0

75c:35ffffe1 cbnzw1, 758 // 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)

;

}

关于volatile更多使用建议可以参考这里。c6a6308114f401be7df747ae46f2b4db.png

评论:

steven

2019-07-17 16:03

:

...

ldr    w0, [x0]    // load b to w0

add    w1, w0, #0x1

...

str    w1, [x0]    // a = b + 1   这个地方有问题,str  w1, [x0]不应该是x0,否则变成b=b+1

...

str    wzr, [x0]    // b = 0

2019-07-17 23:08

@steven:这个是其实是汇编直接copy过来的,中间省略部分可能就是改变了x0的值。但是的确这段代码会让人觉得歧义。

石石

2019-07-09 16:21

请问compiler reorder 跟CPU execution reorder 该如何理解?

当使用了barrier(), 让compiler正确的编译出正确的顺序(program order),

却没有对cpu out-of-order execution做出确保(execution order)?

对一个C programmer而言, 这样的问题该理解到哪个层次, 才能确保程式正确性?

石石

2019-07-09 16:38

@石石:如果cpu有OOOE, 不能单纯用 __asm__ __volatile__("": : :"memory")

应该使用__asm__ __volatile__("dmb": : :"memory"),

machine-specific implementation 应该使用 __sync_synchronize().

2019-07-09 23:06

@石石:是的,这篇文章其实算是乱序的上篇,仅仅是编译器乱序。然而CPU也是存在乱序的,所以CPU乱序部分的文章还没有发表。后面会补上。

steven

2019-03-05 20:25

add    w0, w2, #0x13 有笔误,应该是#0x1吧

2019-03-07 22:27

@steven:是的,感谢。

威点零

2019-02-19 11:55

问个问题,为什么我们正常写程序的时候,不需要考虑加内存屏障?

所以是不是要加上限定关系?比如有前后因果关系的指令不会重排,用户层面全局变量使用互斥锁能防止重排?

2019-02-24 20:54

@威点零:考虑屏障,一般都是无锁编程需要。如果使用同步原语(自旋锁,信号量,互斥锁等)保护共享资源,我们是不需要考虑内存屏障的。因为,同步原语的实现已经帮助我们考虑好了。

xtzt

2019-01-24 14:22

写的很好!!!

yayaya

2019-01-24 10:44

编译器(compiler)的工作就是优化我们的代码以提高性能??

2019-01-24 10:54

@yayaya:很明显其中之一的功能嘛!

yayaya

2019-01-24 11:29

@smcdef:我处女座^_^

发表评论:

昵称

邮件地址 (选填)

个人主页 (选填)

d4e3789769c8ad44c7e403863bfc3822.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值