为什么我们需要内存屏障?

为什么我们需要内存屏障?

常见的cpu架构中,都有对内存屏障指令[1]的支持,比如x86的mfence/sfence/lfence指令,mips的sync指令等,各种用法这里就不多写了。这篇文章,主要想漫谈下内存屏障[2]的实现和内存一致性模型[3]相关的东西。

在上一篇文章[4]中,我写到了原子操作的实现。在文章中,我简单介绍了MESI这个cache一致性协议。MESI能够保障在cpu1上能够读取到在cpu2上已写入cache,但未换入内存的修改。看似此时的内存模型的一致性(后续会详细介绍一致性模型)保障已足够严格,但并如此。让我们来看看wiki上给出的自旋锁spinlock[5]的实现:

; Intel syntax

locked:                      ; The lock variable. 1 = locked, 0 = unlocked.
     dd      0

spin_lock:
     mov     eax, 1          ; Set the EAX register to 1.

     xchg    eax, [locked]   ; Atomically swap the EAX register with
                             ;  the lock variable.
                             ; This will always store 1 to the lock, leaving
                             ;  the previous value in the EAX register.

     test    eax, eax        ; Test EAX with itself. Among other things, this will
                             ;  set the processor's Zero Flag if EAX is 0.
                             ; If EAX is 0, then the lock was unlocked and
                             ;  we just locked it.
                             ; Otherwise, EAX is 1 and we didn't acquire the lock.

     jnz     spin_lock       ; Jump back to the MOV instruction if the Zero Flag is
                             ;  not set; the lock was previously locked, and so
                             ; we need to spin until it becomes unlocked.

     ret                     ; The lock has been acquired, return to the calling
                             ;  function.

spin_unlock:
     mov     eax, 0          ; Set the EAX register to 0.

     xchg    eax, [locked]   ; Atomically swap the EAX register with
                             ;  the lock variable.

     ret                     ; The lock has been released.

但是上述的代码在有些cpu上是没法正常工作的,这取决于xchg eax, [locked]这句指令中,xchg指令的实现中是否使用了内存屏障来保障内存读写顺序。

那内存屏障究竟具体做了什么呢?先看下面一张图:



cpu对寄存器,store buffer,load buffer的访问是远远快于cache和memory的。而且在现代cpu的设计中,提升性能的主要方式就是提升并行度。而提升指令级并行的两种主要方式,包括增加流水线深度,和多发射流水线(每条流水线执行多条指令)。由于每条指令的执行时间由流水线上最耗时的阶段来决定,因此增加流水线的深度后,可以缩短每条指令的执行耗时。不过因为流水线每个阶段之间传递数据的耗时无法忽略,因此可增加的流水线的深度会存在上限。所以,另外一种方法就是让每条流水线执行多条指令。实现多发射流水线的方式包括静态多发射,和动态多发射。静态多发射可以理解为一条指令包含多个操作(VLIW[6])。动态多发射也叫超标量技术[7],一条流水线存在多个执行单元,可用于执行例如浮点计算,整型计算,store/load等。多条指令可以根据其依赖关系,实现并行执行。而且由于store/load buffer的存在,写入数据不必立马刷入内存,读取数据可以提前读取,因此可以进一步提升并行度。同时,对于分支指令,存在speculation execution(推测执行),最近爆出的cpu漏洞就是和这个技术有关。因此,从上面介绍的可知,我们的程序指令其实是乱序执行的。不过,为了保证正确性,执行后的结果却是顺序提交的,这样就防止出现分支预测失败,而导致执行了不该被执行的指令。在乱序执行的过程中,结果只会被暂存到物理寄存器和buffer中,只有当提交的时候,才会将更改写入ISA(指令级架构)寄存器和cache中。因此,当出现预测失败的时候,只要丢弃掉执行的结果就行,未提交的指令的修改不会对外暴露出来。多说一句,我们写汇编时候用到的寄存器其实是逻辑寄存器,或者说ISA寄存器,它们和真实的物理寄存器的之间存在映射关系。

通过我上述的介绍可以知道,首先,cpu写入的数据不会也没有必要直接写到cache中,而是会缓存在寄存器和store buffer中,读取到的数据也会缓存在load buffer中。然后,读写指令由于存在指令并行优化,顺序会被打乱。

写到这里,应该可以明白了,内存屏障的外在表现形式是:
阻止读写内存动作的重排序。比如,mfence阻止mfence指令之后的读写内存的动作被重排序到mfence之前,以及阻止mfence指令被重排序到更早的读写内存动作之前。sfence和mfence类似,但只是阻止写内存动作的重排序。lfence同样和mfence类似,但只是阻止读内存动作的重排序。

而内存屏障的实现,以x86架构举例:

sfence/mfence会将store buffer中缓存的修改刷入L1 cache中,使得其他cpu核可以观察到这些修改,而且sfence/mfence之后的写操作,不会被调度到sfence/mfence之前。

从intel的官方文档[8]可以看到,早期的intel处理器,比如intel486和Pentium,是遵循sequential consistency[9]的,包括保证多核上观察到的写入顺序是一致的,和保证程序中的读写序不会被打乱,这个时候并不需要内存屏障来保障memory order的。 这个时候,读读,读写,写读,写写均不会乱序。

但是在新版本的intel的cpu实现中,为了提升性能,打破了上述的约束。
首先,允许预读取操作,将数据提前存到load buffer中。
然后,允许store buffer的缓存,因此导致之前的写->读操作,变成读->写操作。
最后,字符串操作指令在单指令内部,会存在乱序(启动fast string模式下);绕过cache的写指令(MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, and MOVNTPD)之间,可以存在指令的重排序。
因此,此时需要内存屏障的出现,来保障memory order。 顺便说下,对x86架构下使用的x86-TSO内存一致性模型感兴趣的,可以看这里[10]

上面,我们提到了sequential consistency,但是cpu在不断优化的过程中,会降低一致性的要求,来逐渐提升性能。此时,为了保障程序的正确,需要开发者使用例如内存屏障这些工具,来保障一致性。因此,cpu选择实现怎样的一致性内存模型,本质上是以降低指令排序约束来提升并发度,和提升编程复杂度,这两者之间的一个tradeoff的过程。


文章原址在这 :

为什么我们需要内存屏障? · Issue #4 · luohaha/MyBloggithub.com图标

编辑于 2018-02-10

文章被以下专栏收录