内存屏障 -- Part 1

1. 什么是内存屏障

内存屏障 (Memory Barrier, membar, memory fence, or fen instruction) ,是一种屏障指令,使得CPU以及编译器对于内存的操作强制执行顺序约束,在屏障指令前或者后的内存操作不进行指令重排。这就意味着,在屏障之前的指令保证在屏障之后的指令前执行。

内存屏障的实现涉及硬件架构层面的知识,同时需要操作系统、编译器的配合,需要从这三个层面理解内存屏障。

众所周知,当一个执行中的程序的数据被多个线程并发访问的时候,就会涉及到同步(Synchronization)的问题。同步的目的是保证不同执行流对共享数据并发操作的一致性。早在单核时代,使用锁或者原子变量就很容易达成这一目的。甚至因为CPU的一些访存特性,对某些内存对齐数据的读或写也具有原子的特性。

同时,从代码,到可执行程序,再到cpu实际执行过程的指令,会经过不同层面的优化,保证结果一致的同时,尽可能提高效率,这就造成了指令的乱序执行(Out-of- order execution)。在单核时代可以简单保证的原子性一致性,在多线程场景中,需要额外的同步措施才能保证。

排序约束的确切性质是依赖于硬件的,并由体系结构的内存排序模型定义。一些体系结构提供了多种屏障来执行不同的排序约束。内存屏障通常用于实现在多个设备共享的内存上操作的低级机器代码。这样的代码包括多处理器系统上的同步原语和无锁数据结构,以及与计算机硬件通信的设备驱动程序。

1.2 为什么要有内存屏障

  • 编译器代码优化以及指令重排,而可能改变代码执行顺序

  • cpu乱序执行(Out-of- order execution),单核时代的Self-Consistent 特性再多核时代无法满足

  • cpu多级缓存,多线程操作时需要保证缓存一致性(Cache Coherence)

前两点都属于顺序一致性问题(Sequential Consistency)

2. 可见性

可见性定义常见于各种并发场景中,以多线程为例:当一个线程修改了共享变量的值,其他线程可以立刻得知这个修改。

从性能角度考虑,没有必要在修改后就立即同步修改的值——如果多次修改后才使用,那么只需要最后一次同步即可,在这之前的同步都是性能浪费。因此,实际的可见性定义要弱一些,只需要保证:当一个线程修改了线程共享变量的值,其它线程在使用前,能够得到最新的修改值

可见性可以认为是最弱的“一致性”(弱一致),只保证用户见到的数据是一致的,但不保证任意时刻,存储的数据都是一致的(强一致)。下文会讨论“缓存可见性”问题,部分文章也会称为“缓存一致性”问题。

2.1 缓存一致性

开始提到Cache一致性协议之前,先介绍两个名词:

  • Load/Read CPU读操作,是指将内存数据加载到寄存器的过程

  • Store/Write CPU写操作,是指将寄存器数据写回主存的过程

现代处理器缓存分为三级,由每一个核心独有的L1, L2 cahce, 以及所有核心共享的L3 cache. 准确地说,每个核上有两个L1 Cache, 一个存数据 L1d Cache, 一个存指令 L1i Cache。

  • L1 Cache最接近CPU, 容量最小(如32K、64K等)、速度最高,每个核上都有一个L1 Cache。

  • L2 Cache容量更大(如256K)、速度更低, 一般情况下,每个核上都有一个独立的L2 Cache。

  • L3 Cache最接近内存,容量最大(如12MB),速度最低,在同一个CPU插槽之间的核共享一个L3 Cache。

由于Cache 容量较小,一般使用局部性原理,按行/块与内存进行数据交换,以提升访问效率。既然各个核心之间有独立的Cache存储器,那么这些存储器之间的数据同步就是个比较复杂的事情。缓存数据的一致性由缓存一致性协议保证。这里比较经典的当属MESI协议。Intel的处理器使用从MESI中演化出的MESIF协议。

要解决不同核心中,缓存不一致的问题,只需要在其中一个核心C0 修改了共享数据v之后,令核心C1 在使用前可以得到v的最新值。

这个要求很弱,既可以在每次修改v后,都同步修改值到其他缓存了v的Cache中;又可以只同步使用前的最后一次修改值。后者性能上更优,如何实现呢:

  • Core0修改v后,发送一个信号,将Core1缓存的v标记为失效,并将修改值写回内存。

  • Core0可能会多次修改v,每次修改都只发送一个信号(发信号时会锁住缓存间的总线),Core1缓存的v保持着失效标记。

  • Core1使用v前,发现缓存中的v已经失效了,得知v已经被修改了,于是重新从其他缓存或内存中加载v。

实际的可见性问题,还需要扩展到两个方面:

  • 除三级缓存外,各厂商实现的硬件架构中还存在多种多样的缓存,都存在类似的可见性问题。例如,寄存器就相当于CPU与L1 Cache之间的缓存。

  • 各种高级语言(包括Java)的多线程内存模型中,在线程栈内自己维护一份缓存是常见的优化措施,但显然在CPU级别的缓存可见性问题面前,一切都失效了。

2.2 缓存可见性导致伪重排序

假设程序顺序(program order)中先更新变量v1、再更新变量v2,不考虑真·重排序:

  • Core0先更新缓存中的v1,再更新缓存中的v2(位于两个缓存行,这样淘汰缓存行时不会一起写回内存)。

  • Core0读取v1(假设使用LRU协议淘汰缓存)。

  • Core0的缓存满,将最远使用的v2写回内存。

  • Core1的缓存中本来存有v1,现在将v2加载入缓存。

此时尽管v1的更新事件早于v2的更新,但是Core1只看到了v2的最新值,看不到v1 的最新值,这属于可见性导致的伪重排:虽然没有实际进行重排序,但是具有指令重排的效果

3. 重排序

除去可见性导致的伪重排序,实际的重排序包括编译器的优化,以及处理器的乱序执行。

3.1 编译器优化

与处理器乱序执行目的一致,与其等阻塞指令刷入内存,不如去执行其他指令,从而提高效率。与cpu乱序执行相比,编译器重排序可以完成更大范围,效果更好的乱序优化。在Java 中,可以通过volatile 关键字禁止编译器重排序。

3.2 处理器乱序执行

处理器层面的乱序优化节省了大量等待时间,提高了处理器的性能。 所谓“乱序”只是被叫做“乱序”,实际上也遵循着一定规则:只要两个指令之间不存在数据依赖,就可以对这两个指令乱序。不必关心数据依赖的精确定义,可以理解为:只要不影响程序单线程、顺序执行的结果,就可以对两个指令重排序。

4. 内存屏障举例

Java 通过volatile 解决了编译器层面的可见性以及重排序的问题。而内存屏障则解决了硬件层面的可见性以及重排序问题。

屏障类型

屏障类型

指令示例

说明

LoadLoad barrier

Load1;LoadLoad;Load2

该屏障保证Load1的数据读取先于Load2及其之后的数据读取

StoreStore Barrier

Store1;StoreStore;Store2

保证Store1的数据写入立即刷入内存;先于Store2及其后的存储操作

LoadStore Barrier

Load1; LoadStore; Store2

保证Load1 的数据读取先于Store2的数据写入

StoreLoad Barrier

Store1; StoreLoad; Load2

保证Store1 的数据写入立即刷入内存,优先于Load2及其之后所有的数据读写操作。使得该屏障之前的数据操作都在屏障前结束,屏障之后的数据操作都在屏障后开始

StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。

然而,除了mfence,不同的CPU架构对内存屏障的实现方式与实现程度非常不一样。相对来说,Intel CPU的强内存模型比DEC Alpha的弱复杂内存模型(缓存不仅分层了,还分区了)更简单。x86架构是在多线程编程中最常见的,下面讨论x86架构中内存屏障的实现。

X86平台的内存屏障

  • Store Barrier

    sfence 指令实现了写屏障,相当于StoreStore Barrier。强制所有在sfence指令之前的store指令,都在该sfence指令执行之前被执行,发送缓存失效信号,并把store buffer中的数据刷出到CPU的L1 Cache中;所有在sfence指令之后的store指令,都在该sfence指令执行之后被执行。即,禁止对sfence指令前后store指令的重排序跨越sfence指令,使所有Store Barrier之前发生的内存更新都是可见的。

    这里的“可见”,指修改值可见(内存可见性)且操作结果可见(禁用重排序)。下同。

  • Load Barrier

    lfence 指令实现了读屏障,相当于LoadLoad Barrier。强制所有在lfence指令之后的load指令,都在该lfence指令执行之后被执行,并且一直等到load buffer被该CPU读完才能执行之后的load指令(发现缓存失效后发起的刷入)。即,禁止对lfence指令前后load指令的重排序跨越lfence指令,配合Store Barrier,使所有Store Barrier之前发生的内存更新,对Load Barrier之后的load操作都是可见的。

  • Full Barrier

    mfence实现了全屏蔽,相当于StroreLoad Barrier。mfence指令综合了sfence指令与lfence指令的作用,强制所有在mfence指令之前的store/load指令,都在该mfence指令执行之前被执行;所有在mfence指令之后的store/load指令,都在该mfence指令执行之后被执行。即,禁止对mfence指令前后store/load指令的重排序跨越mfence指令,使所有Full Barrier之前发生的操作,对所有Full Barrier之后的操作都是可见的。

Last updated