编译器重排序

在一些讲Java并发编程的书中,经常会出现JMM内存模型、volatile关键字、重排序、乱序执行等字眼,导致了有些刚开始学习Java并发编程的小伙伴一脸懵逼:这都是啥啊?

今天我们就来说一下这些书中提到的一个概念,编译器重排序。

这里先说明一点,下提到的编译器指的是将高级语言编译成汇编语言(机器码)的编译器(比如说GCC、clang、甚至JIT),而不是Javac这种编译成中间语言(JVM字节码)的编译器,当然不同的Java编译器生成的JVM字节码的方式可能也各有不同,但这并不在本文的讨论范围之内。

注:本文实验环境基于Windows10中wsl2下的debian版本
在这里插入图片描述

1. 什么是编译器重排序

1.1 定义

编译器重排序:编译器会对高级语言(本文里特指C/C++)的代码进行分析,当编译器认为你的代码可以优化的话,编译器会选择对代码进行优化然后生成汇编代码,当然编译器的优化满足特定的条件,这里要说一下大名鼎鼎的as-if规则:

Allows any and all code transformations that do not change the observable behavior of the program.

也就是说在不影响这段代码结果的前提下,编译器可以使用任意一种方式对代码进行编译,这也就给了编译器充分的空间对代码进行优化,从而提高代码的运行效率。

1.2 举个例子

下面是一段简单的C语言代码:

int a, b;
void foo(void)
{
	a = b + 11;
	b = 0;
}

这段代码逻辑很简单,但是对于编译器来说,问题就没有这么简单了。我们来看一下使用aarch64-linux-gnu-gcc使用-O2参数,让编译器在O2级别优化的情况下编译上述代码(得到RAM汇编),使用objdump工具查看foo()方法反汇编结果:

0000000000000750 <foo>:
 750:   90000080        adrp    x0, 10000 <__FRAME_END__+0xf6b8>
 754:   90000081        adrp    x1, 10000 <__FRAME_END__+0xf6b8>
 758:   f947dc00        ldr     x0, [x0, #4024] // 取b内存地址
 75c:   f947e821        ldr     x1, [x1, #4048] // 取a内存地址
 760:   b9400002        ldr     w2, [x0]        // 寄存器w2 = b(内存地址)
 764:   b900001f        str     wzr, [x0]       // b(内存地址) = 0
 768:   11002c40        add     w0, w2, #0xb    // 寄存器w0 = b + 11 
 76c:   b9000020        str     w0, [x1]        // w0寄存器的值存入a(内存)
 770:   d65f03c0        ret
 774:   d503201f        nop

我们发现,编译得到的汇编代码和我们原本的C语言代码不顺序并不一致,而是相当于如下C语言代码:

	int a, b;
	void foo(void)
	{
   		b = 0;
		a = b + 11;
	}

1.3 重排序原因

为什么会出现这种情况呢?

编译器的本意是提升程序在CPU上的运行性能,更好的使用寄存器以及现代处理器的流水线技术,减少汇编指令的数量,降低程序执行需要的CPU周期,减少CPU读写主存的时间,但是在多核多线程并行的情况下,这种重排序优化就有可能导致共享变量的可见性问题。

当然编译器的优化也不仅限于对于代码的重排序,编译器还会优化掉它认为不需要的一些变量,同时也会将一些本应去内存中取得数据存入寄存器中,然后下次取得时候就可以直接从寄存器中获取(这样也可能导致多线程中共享变量的可见性问题)。

在这里插入图片描述

当然,as-if规则在单核CPU时代是完全没有问题的,但是随着CPU的发展,出现了可以多核并行的CPU,这时编译器重排序就可能导致一些令人意想不到的问题,这点我们从感性认知上就可以理解,因为在多线程编程中经常会使用一些共享变量来实现不同线程的控制或者数据传输,但是如果编译器把我们精心设计的代码顺序进行了“优化“,就有可能出现我们不希望出现的运行结果。

1.4 不仅仅是重排序

前面我一直想用“优化"这个词,而不是用”重排序”这个词,是因为编译器对于代码的优化不仅限于重排序,编译器同时会删除一些它认为无用的代码,更重要的是,会把一些变量放进寄存器中!

举个例子来说:

	int run = 1;
	 
	void foo(void)
	{
		while (run) // doSomething…
	          ;
	}

aarch64-linux-gnu-gcc –O2 编译后得到:

 740:   90000080        adrp    x0, 10000 <__FRAME_END__+0xf6d8>
 744:   f947e000        ldr     x0, [x0, #4032] // 取run内存值,存入x0
 748:   b9400000        ldr     w0, [x0]         // 取x0值存入w0
 74c:   d503201f        nop
 750:   35000000        cbnz    w0, 750 <foo+0x10> // 跳到750也就是本行
 754:   d65f03c0        ret

这里需要解释一下,cbnz命令是ARM汇编中的一个指令,cbnz w0, 750 <foo+0x10>的意思是,如果寄存器w0中的值不等于0,那么跳转到后面那行代码,但是问题来了,这里跳转以后并没有重新去取内存中run的值,而是直接从寄存器取值然后判断,也就是说这段代码理论上会一直运行下去,即使别的线程会去更改内存中run的值,其实这就导致了多线程中共享变量的可见性问题。

2. 如何禁止?

2.1 编译器屏障

之前已经说了,多线程情况下编译器优化会导致一些问题的出现,那么有没有方法来阻止编译器的优化呢?答案是肯定的,而且方式还不止一种:

  1. 将变量声明为volatile变量(注意:Java中的volatile变量更强大)
  2. 代码中插入编译器屏障(Compiler Barrier),阻止编译器对屏障前后的代码进行优化,因此编译器屏障也被叫做优化屏障(Optimization Barrier)

为了防止读者误解,在这里先做说明:C/C++中将变量声明为volatile相当于对这个变量的每一次操作前后插入一个编译器屏障。了解了这一点前提后,我们就能更好的解释后续的一些概念。

编译器屏障的作用是什么?阻止编译器对屏障前后的代码进行重排序优化,同时阻止编译器将变量置入寄存器中随后直接使用,而是需要取内存中(或者CPU缓存中)的变量值进行运算操作。简而言之,就是禁止编译屏障前后编译器对于变量操作的优化(重排序、从寄存器中取值使用)

2.2 禁止重排序

我们来看上述代码插入编译器屏障以后编译得到的效果:

	#define barrier() __asm__ __volatile__("": : :"memory")
	
	int a, b;
	void foo(void)
	{
		a = b + 11;
		barrier(); // 插入编译器屏障(优化屏障)
		b = 0;
	}

编译后,没有出现重排序现象,汇编代码和C代码顺序一致。

0000000000000750 <foo>:
 750:   90000080        adrp    x0, 10000 <__FRAME_END__+0xf6b8>
 754:   90000081        adrp    x1, 10000 <__FRAME_END__+0xf6b8>
 758:   f947dc00        ldr     x0, [x0, #4024] // x0存入b内存地址
 75c:   f947e821        ldr     x1, [x1, #4048] // x1存入a内存地址
 760:   b9400002        ldr     w2, [x0]        // w2存入x0值
 764:   11002c42        add     w2, w2, #0xb    // w2 = b + 11;
 768:   b9000022        str     w2, [x1]        // 内存中a = w2
 76c:   b900001f        str     wzr, [x0]       // 内存中b = 0
 770:   d65f03c0        ret
 774:   d503201f        nop

先解释一下:

#define barrier() __asm__ __volatile__("": : :"memory") 时一段内嵌汇编代码,__asm__ 代表C语言内嵌汇编代码,__volatile__ 是告诉编译器不要把这行代码进行任何优化,
(“”: : :”memory”)这个比较复杂,但是在这里只需要知道,这段代码的意思是告诉编译器“内存发生了改变”,因此GCC编译时就会知道,不能使用寄存器中的值,而是要去内存中取值,且不能将屏障前后的代码重排序

可以看到,使用了编译器屏障以后,代码并没有进行重排序,之前也提到编译器还会对代码进行优化,将本来应该从内存中取值的变量放在寄存器中,那么编译器屏障能解决这个现象吗?

2.3 禁止寄存器存/取值

本文之前提到,编译器会将变量放入CPU寄存器中,减少访问内存(缓存)耗时,但是有些情况下放入寄存器会导致多线程环境下的变量不可见性。

那么编译器屏障能解决这个问题吗?我们看之前的代码插入编译器屏障以后:

#define barrier() __asm__ __volatile__("": : :"memory")
int run;

void foo(void)
{
        while(run)
        barrier();
}

反编译得到汇编代码:

0000000000000740 <foo>:
 740:   90000081        adrp    x1, 10000 <__FRAME_END__+0xf6d0>
 744:   f947e021        ldr     x1, [x1, #4032] // 取run内存地址
 748:   b9400020        ldr     w0, [x1]         // w0寄存器取run内存值
 74c:   34000060        cbz     w0, 758 <foo+0x18> // w0为0跳转到758行
 750:   b9400020        ldr     w0, [x1]          // w0寄存器取run内存值
 754:   35ffffe0        cbnz    w0, 750 <foo+0x10> //w0不为0跳转750行
 758:   d65f03c0        ret
 75c:   d503201f        nop

添加屏障以后,汇编代码和之前发生了变化,主要看754行,这次比较以后跳转到了750行,也就是又取了一遍内存中的run值,再判断是否为0,相较于之前的跳转到本行的行为,相当于消除了编译器对于变量存入寄存器的优化。

2.4 volatile(C/C++)

相应的,也可以通过把run变量声明为volatile变量,告诉编译器这个变量的不可优化

int volatile run;

void foo(void)
{
        while(run)
}

编译后得到汇编代码:

0000000000000740 <foo>:
 740:   90000081        adrp    x1, 10000 <__FRAME_END__+0xf6d8>
 744:   f947e021        ldr     x1, [x1, #4032]   // 取run内存地址 
 748:   b9400020        ldr     w0, [x1]          // w0取run内存值
 74c:   35ffffe0        cbnz    w0, 748 <foo+0x8> // w0不为0就跳转到748行
 750:   d65f03c0        ret
 754:   d503201f        nop

可以看到,跳转到了748行,需要从新从内存中取run的值再进行比较,也就是和插入编译器屏障一样。当然volatile也可以阻止编译器重排序,读者可以自行尝试。

C/C++中的volatile关键字作用和Java中是不同的,Java中volatile关键字相当于C/C++的加强版,至于怎么进行加强,以后我会着重说一说。

C/C++中的volatile关键字,我之说过,相当于对这个变量前后插入了内存屏障,其实这样说有些不够精确,其核心作用就是禁止编译器对于这个变量/代码块进行任何优化,禁止重排序、禁止使用寄存器而不取内存值、禁止编译器将其认为无用的代码优化掉

但是Linux内核编程中是很抵制程序员使用volatile关键字的,因为Linux本身对于同步控制提供了各种API,都可以替代直接使用volatile关键字,其实Linux和JVM设计思路上有些一直,屏蔽了这些API的实现细节,就像JVM屏蔽了volatilesynchronized关键字的实现细节。但是有一点不得不说,无论是Linux还是JVM,底层都使用到了编译器屏障来防止一些问题的出现。

3. 结束语

关于编译器重排序的问题以及解决方案到这里已经说完了,这时候有人就问了,你说了这么多好像和本文一开始提到的JMM内存模型、volatile关键字等等没有多大关系?

导致多线程出现可见性和顺序性问题的一个原因——编译器优化,本文已经揭秘了,JVM为了实现JMM的规则,其实底层用到了大量的编译器屏障来阻止编译器对于一些代码的“优化”,只不过对于Java程序员来说,是感知不到这些的,而且JVM的实现还考虑到了跨平台的实现:对于x86、ARM、PowerPC等等平台都能完美实现。

但是导致多线程可见性和顺序性问题还有另一个原因,这个原因究其根本还是CPU设计上的种种“优化”导致的,当然JVM为了满足JMM模型也是颇费苦心来解决这个问题,这里先卖个关子,下篇文章会揭示这个原因和其解决方案。

因为我自身对于ARM汇编知之甚浅,所以如果文章中存在一些问题,希望大家批评指出。


  1. 蜗窝科技 – 编译乱序(Compiler Reordering) – 本文主体基于此
  2. ARM(CM3)的汇编指令
  3. asm __volatile__内嵌汇编用法简述
  4. Why Memory Barriers?中文翻译 – 深度好文
  5. The Linux Kernel – Why the “volatile” type class should not be used
  • 6
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值