为什么要有内存屏障
为了解决cpu,高速缓存,主内存带来的的指令之间的可见性和重序性问题。
我们都知道计算机运算任务需要CPU和内存相互配合共同完成,其中CPU负责逻辑计算,内存负责数据存储。CPU要与内存进行交互,如读取运算数据、存储运算结果等。由于内存和CPU的计算速度有几个数量级的差距,为了提高CPU的利用率,现代处理器结构都加入了一层读写速度尽可能接近CPU运算速度的高速缓存来作为内存与CPU之间的缓冲:将运算需要使用
的数据复制到缓存中,让CPU运算可以快速进行,计算结束后再将计算结果从缓存同步到主内存中,这样处理器就无须等待缓慢的内存读写了。
什么是内存屏障
内存屏障,也称内存栅栏,内存栅障,屏障指令等, 是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。
程序编译优化、cache访问优化、多核等导致CPU指令乱序执行,最终程序运行不符合我们预期。内存屏障会设置一个同步点,保障屏障前后的多核内存访问数据的一致性。
问题的由来
造成乱序访问的原因分为两类:一类是主动的,编译器会主动重排代码使得特定的cpu执行更快,称之为编译乱序。另外一类是被动的,为了异步化指令的执行,引入Store Buffer和Invalidate Queue,却导致了指令顺序改变的副作用。
1)指令重排序
上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。
2)store buffer
加入了这个硬件结构后,CPU0需要往某个地址中写入一个数据时,它不需要去关心其他的CPU的local cache中有没有这个地址的数据,它只需要把它需要写的值直接存放到store buffer中,然后发出invalidate的信号,等到成功invalidate其他CPU中该地址的数据后,再把CPU0存放在store buffer中的数据推到CPU0的local cache中。每一个CPU core都拥有自己私有的store buffer,一个CPU只能访问自己私有的那个store buffer。
该硬件同时也有缺陷,每个CPU的store buffer不能实现地太大,其存储队列的数目也不会太多。当CPU以中等的频率执行store操作的时候(假设所有的store操作都导致了cache miss),store buffer会很快的呗填满。在这种情况下,CPU只能又进入阻塞状态,直到cacheline完成invalidation和ack的交互后,可以将store buffer的entry写入cacheline,从而让新的store让出空间之后,CPU才可以继续被执行。
3)Invalidate Queues:
store buffer之所以很容易被填满,主要是因为其他CPU在回应invalidate acknowledge比较慢,如果能加快这个过程,让store buffer中的内容尽快写入到cacheline,那么就不会那么容易被填满了。CPU其实不需要完成invalidate就可以回送acknowledgement消息,这样就不会阻止发送invalidate的那个CPU进去阻塞状态。CPU可以将这些接收到的invalidate message存放到invalidate queues中,然后直接回应acknowledge,表示自己已经收到请求,随后会慢慢处理,当时前提是必须在发送invalidate message的CPU发送任何关于某变量对应cacheline的操作到bus之前完成。
4)乱序处理器
类比工业流水线,一条指令的执行可以分拆为多步:获取、解码、运算和结果的写入,每个步骤由一个特定的功能模块执行,如此拆分的好处是多条执行变串行执行为并行执行
5)什么场景下需要使用内存屏障
在两个线程之间存在需要通过共享内存来实现交互的可能时,才需要使用内存屏障,,保证共享变量的可见性。
内存屏障指令
处理器重排序类型
下面是常见处理器允许的重排序类型的列表:
处理器 | Load-Load | Load-Store | Store-Store | Store-Load | 数据依赖 |
spare-TSO | N | N | N | Y | N |
X86 | N | N | N | Y | N |
ia64 | Y | Y | Y | Y | N |
PowerPC | Y | Y | Y | Y | N |
上表单元格中的“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。
从上表我们可以看出:
1)常见的处理器都允许Store-Load重排序;
2)常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO和x86拥有相对较强的处理器内存模型,它们仅允许对写-读操作做重排序(因为它们都使用了写缓冲区)。
JVM内存屏障指令分类
屏障类型 | 指令示例 | 说明 |
LoadLoadBarriers | Load1; LoadLoad; Load2; |
确保Load1的数据加载,前于Load2及所有后续装载指令的装着 |
StoreStoreBarriers | Store1; StoreStore; Store2; |
确保Store1数据对其他处理器可见(刷新到内存),前于Store2及所有后续存储指令的存储。 |
LoadStoreBarriers | Load1; LoadStore; Store2; |
确保Load1数据装载,前于Store2及所有后续的存储指令刷新到内存。 |
StoreLoadBarriers | Store1; StoreLoad; Load2; |
确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。 |
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。