并发-重排序

1. 介绍

1.1 为什么需要重排序

简单的来说就是为了优化程序性能。

1.2 简介

重排序是编译器和处理器为了优化程序性能而对指令序列进行重排序。从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:

1.3 为什么重排序会提高性能

现代CPU几乎都采用流水线机制加快指令的处理速度,一般来说,一条指令需要若干个CPU时钟周期处理,而通过流水线并行执行,可以在同等的时钟周期内执行若干条指令,具体做法简单的说就是把指令分为不同的执行周期,例如读取、寻址、解析、执行等步骤,并放在不同的元件中处理,同时在执行单元EU中,功能单元被分为不同的元件,例如加法元件、乘法元件、加载元件、存储元件等,可以进一步实现不同的计算并行执行。而流水线技术最怕的就是中断,因此编译器的重排序、处理器的乱序执行、以及内存系统的重排序都是为了减少中断

1.4 编译器优化重排序

编译器重排序的典型就是通过调整指令顺序,在不改变程序语义的前提下,尽可能减少寄存器的读取、存储次数,充分复用寄存器的存储值。

假设第一条指令计算一个值赋给变量A并存放在寄存器中,第二条指令与A无关但需要占用寄存器(假设它将占用A所在的那个寄存器),第三条指令使用A的值且与第二条指令无关。那么如果按照顺序一致性模型,A在第一条指令执行过后被放入寄存器,在第二条指令执行时A不再存在,第三条指令执行时A重新被读入寄存器,而这个过程中,A的值没有发生变化。通常编译器都会交换第二和第三条指令的位置,这样第一条指令结束时A存在于寄存器中,接下来可以直接从寄存器中读取A的值,降低了重复读取的开销

1.5 指令级并行重排序

指令集并行的重排序是对CPU的性能优化,从指令的执行角度来说一条指令可以分为多个步骤完成,如下:

  • 取指 IF

  • 译码和取寄存器操作数 ID

  • 执行或者有效地址计算 EX

  • 存储器访问 MEM

  • 写回 WB

CPU在工作时,需要将上述指令分为多个步骤依次执行(注意硬件不同有可能不一样),由于每一个步会使用到不同的硬件操作,比如取指时会只有PC寄存器和存储器,译码时会执行到指令寄存器组,执行时会执行ALU(算术逻辑单元)、写回时使用到寄存器组。为了提高硬件利用率,CPU指令是按流水线技术来执行的,如下:

 

从图中可以看出当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样做是有好处的,如果每个步骤花费1ms,那么如果第2条指令需要等待第1条指令执行完成后再执行的话,则需要等待5ms,但如果使用流水线技术的话,指令2只需等待1ms就可以开始执行了,这样就能大大提升CPU的执行性能。虽然流水线技术可以大大提升CPU的性能,但不幸的是一旦出现流水中断,所有硬件设备将会进入一轮停顿期,当再次弥补中断点可能需要几个周期,这样性能损失也会很大,就好比工厂组装手机的流水线,一旦某个零件组装中断,那么该零件往后的工人都有可能进入一轮或者几轮等待组装零件的过程。因此我们需要尽量阻止指令中断的情况,指令重排就是其中一种优化中断的手段,我们通过一个例子来阐明指令重排是如何阻止流水线技术中断的

a = b + c ;
d = e + f ;

下面通过汇编指令展示了上述代码在CPU执行的处理过程

 

  • LW指令 表示 load,其中LW R1,b表示把b的值加载到寄存器R1中
  • LW R2,c 表示把c的值加载到寄存器R2中
  • ADD 指令表示加法,把R1 、R2的值相加,并存入R3寄存器中。
  • SW 表示 store 即将 R3寄存器的值保持到变量a中
  • LW R4,e 表示把e的值加载到寄存器R4中
  • LW R5,f 表示把f的值加载到寄存器R5中
  • SUB 指令表示减法,把R4 、R5的值相减,并存入R6寄存器中。
  • SW d,R6 表示将R6寄存器的值保持到变量d中

上述便是汇编指令的执行过程,在某些指令上存在X的标志,X代表中断的含义,也就是只要有X的地方就会导致指令流水线技术停顿,同时也会影响后续指令的执行,可能需要经过1个或几个指令周期才可能恢复正常,那为什么停顿呢?这是因为部分数据还没准备好,如执行ADD指令时,需要使用到前面指令的数据R1,R2,而此时R2的MEM操作没有完成,即未拷贝到存储器中,这样加法计算就无法进行,必须等到MEM操作完成后才能执行,也就因此而停顿了,其他指令也是类似的情况。前面阐述过,停顿会造成CPU性能下降,因此我们应该想办法消除这些停顿,这时就需要使用到指令重排了,如下图,既然ADD指令需要等待,那我们就利用等待的时间做些别的事情,如把LW R4,e和 LW R5,f移动到前面执行,毕竟LW R4,e和 LW R5,f执行并没有数据依赖关系,对他们有数据依赖关系的SUB R6,R5,R4指令在R4,R5加载完成后才执行的,没有影响,过程如下:

 

正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样CPU的性能也能带来很好的提升,这就是处理器指令重排的作用。

1.6 内存系统重排序

这个跟之前两个不同的是,其为伪重排序,也就是说只是看起来像在乱序执行而已。对于现代的处理器来说,在CPU和主内存之间都具备一个高速缓存,高速缓存的作用主要为减少CPU和主内存的交互(CPU的处理速度要快的多),在CPU进行读操作时,如果缓存没有的话从主内存取,而对于写操作都是先写在缓存(Store Buffer)中,最后再一次性写入主内存,原因是减少跟主内存交互时CPU的短暂卡顿,从而提升性能,但是延时写入可能会导致一个问题——数据不一致

// CPU1执行以下操作
a = 1;
int i = b;

// CPU2执行下面操作
b = 1;
int j = a;

其执行图如下

从上面图中我们可以看到,对于CPU来说,先将a = 1写入缓存在读取变量b,过后在写入a到主内存,而这个操作从表面上看就变成了先读取变量b,在写入a到主内存,也就是发生了重排序,所以才说这为伪重排序。

       而从上面我们也可以看出,由于CPU1和2写入的时机不同,最终可能导致读到的(a,b)变量有四种情况,分别是(0,0),(0,1),(1,0),(1,1)。例如,在两个缓存未写入主内存的时候就进行变量读取,这时候读到的就为(0,0),其他情况类推。所以Java在实现内存模型的时候会禁止特定类型的重排序。

1.6.1 写缓存区和无效队列

写缓存区 + 无效队列的引入带来了性能提升,但是引来的就是新问题是:重排序 + 可见性。

(1)写缓存区 + 无效队列如何引入重排序问题

如下表,变量data初始值为0,变量ready初始值为false。两个处理器Processor 1和Processor 2在各自的线程上执行上述代码。执行的绝对时间顺序为 S1——>S2——>L3——>L4

Processor 1Processor 2
data = 1; // S1 
ready = true; // S2 
 while(!ready) continue; //L3
 system.out.println(data); //L4
  • 以StoreStore(写又写)操作为例,看写缓冲造成的重排序。如果S1步data值的写操作被写入写缓冲器、还没真正的写到高速缓存中,而S2步的ready值的写操作已经写入到了高速缓存。那在L3步读取ready值时,根据MESI协议,会读到正确的ready值:true;但在L4步读取data时,会读到data的初始值0,而不是在另外一个处理器写缓冲器中的值:修改值1。在处理器Processor 2看来,S1和S2的执行顺序就好像反了一样,即发生了重排序。
  • 以LoadLoad(读又读)为例,看无效化队列造成的重排序。同上面的步骤,S2已被同步到高速缓存,S1写入写缓冲器,并发送了Invalidate消息。当执行L3时,读取到正确的值:true,当执行到L4时,由于无效化队列,Processor 2虽然发送了Invalidate Acknowledge消息,但并没有删除自己高速缓存中的data数据,所以会读取到其高速缓存中的data:0

1.6.2 并不是所有处理器重排序都相同

目前市面上大多处理器应该是x86(我瞎猜的),以X86为例,它就去除了无效队列,并且不会对写-写操作重排序。所有部分可能的现象无法复现,也是因为处理器的不同导致的。

1.7 数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

数据依赖性分为下列三种类型。

上述三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会改变。

编译器和处理器在重排序时,会遵循数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序。

1.8 as-if-serial语义

不管怎么重排序(编译器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

1.9 happens-before

as-if-serial语义保证单线程内程序的执行结果不会被改变,happens-before关系保证正确同步的多线程程序的执行结果不会被改变。as-if-serial和happens-before这么做的目的都是为了在不改变程序执行结果的前提下,尽可能的提高程序执行的并行度。

  • 程序顺序规则:在一个线程内一段代码的执行结果是有序的(在单线程情况下,对不存在数据依赖性的指令进行重排序,只保证单线程执行结果的正确性,不保证程序在多线程中执行的正确性)。
  • 监视器锁规则:就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)
  • volatile变量规则:就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见
  • 线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见
  • 线程终结规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始(finalize:垃圾回收机器(Garbage Collection),也叫GC);
  • 传递性:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;

1.10 重排序规则

重排序的规则是从JAVA语言(高级语言)的角度描述的,因此编译器能够遵守这些规则是因为它 "认识" JAVA语言,而处理仅 "认识" 指令,因此处理器要遵守上述规则就需要借助相关指令(即内存屏障)。

  • 临界区内的操作不允许被重排序到临界区之外;
  • 临界区内的操作之间允许被重排序;
  • 临界区外的操作之间可以被重排序
  • 锁申请与锁释放操作不能被重排序;
  • 两个锁申请操作不能被重排序;
  • 两个锁释放操作不能被重排序;
  • 临界区外的操作可以被重排到临界区之内;

PS:《JAVA多线程编程实战指南 核心篇》第一部分第三章 ---- 锁与重排序

1.11 各处理器重排序规则

2. 源码

3. 实战

4. FAQ

4.1 LoadBuffer和StoreBuffer有什么区别?

A:CPU经过长时间的优化,在寄存器和L1缓存之间添加LoadBuffer、StoreBuffer来降低阻塞时间。LoadBuffer、StoreBuffer,合并排序缓冲(Memoryordering Buffers(MOB)),Load缓冲64长度,Store缓冲36长度,Buffer与L

1进行数据传输时,CPU无须等待。

  • CPU进行load读数据时,把读请求放到LoadBuffer,这样就不用等待其它CPU响应,先进行下面操作,稍后再处理这个读请求的结果。
  • CPU执行sotre写数据时,把数据写到SoreBuffer中,待到适合的时间点,把StoreBuffer的数据刷到内存中。

因为StoreBuffer的存在,CPU在写数据时,真实数据不会立即表现在内存中,所以对其它CPU是不可见的;同样道理,LoadBuffer中的请求也无法拿到其它CPU设置的最新数据;由于StoreBuffer和LoadBuffer是异步执行的,所以在外面看来,先读后写还是先写后读,没有严格区别。

由于引入StoreBuffer和LoadBuffer导致异步模式,从而导致内存数据的读写是乱序的(也就是内存系统的重排序)。

4.2 双重检查锁定(DCL)中描述有问题,问题的来源是对象的创建并非原子性,而是分三步:分配对象内存空间、初始化对象、设置变量指向刚分配的内存地址。而第二步和第三步并不是依赖关系,是允许重排序的。而重排序就导致多线程的可见性问题,即使使用了synchronized。但是临界区内的重排序不会排到临界区外,而synchronized又会阻塞其它线程执行,也就是说线程B无需关心线程A内的重排序,因为锁释放后,可以保证其它线程的可见。那么怎么会还有问题呢?

这里说的DCL的问题并没有描述清楚,其实是指的这个双重检查锁定的方法返回有问题。理想状态下我们要求无论什么时候调getInstance方法都要返回实力化的对象,但是因为创建对象重排序的原因,会导致实例化到一半的时候,也就是分配对象内存空间和设置变量指向刚分配的内存地址的时候,当前线程被其它线程抢占,导致进第一次检查的时候,没检查出null,而直接返回instance实例,这样外部使用的时候可能会出现问题。所以使用volidate可以避免其重排序。

 

6. 参考资料

指令重排序

指令重排序和内存屏障

面试官:谈谈happens-before?

高速缓存,写缓冲器和无效队列

为什么x86中只有storeload可以重排序呢?

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值