1,为什么CPU要乱序执行,难道是考虑性能吗?那为什么乱序就能提升性能?
2,为什么在Intel X86/64架构下,就只有写读(Store Load)发生乱序呢?读读呢?读写呢?
要明白这两个问题,我们首先得知道cache coherency,也就是所谓的cache一致性。在现代计算机里,一般包含至少三种角色:cpu、cache、内存。一般说来,内存只有一个;CPU Core有多个;cache有多级,cache的基本块单位是cacheline,大小一般是64B-256B。每个cpu core有自己的私有的cache(有一级cache是共享的),而cache只是内存的副本。那么这就带来一个问题:如何保证每个cpu core中的cache是一致的?
在广泛使用的cache一致性协议即MESI协议中,cacheline有四种状态:Modified、Exclusive、Shared、Invalid,分别表示修改、独占、共享、无效。当某个cpu core写一个内存变量时,往往是(先)只修改cache,那么这就会导致不一致。为了保证一致,需要先把其他core的对应的cacheline都invalid掉,给其他core们发送invalid消息,然后等待它们的response。这个过程是耗时的,需要执行写变量的core等待,阻塞了它后面的操作。为了解决这个问题,cpu core往往有自己专属的store buffer。等待其他core给它response的时候,就可以先写store buffer,然后继续后面的读操作,对外表现就是写读乱序。
因为写操作是写到store buffer中的,而store buffer是私有的,对其他core是透明的,core1无法访问core2的store buffer。因此其他core读不到这样的修改。
如下代码作为示例讲解:
//假设a和b初始化为0 ,CPU 0执行foo函数,CPU 1执行bar函数。我们再进一步假设a变量
//在CPU 1的cache中,b在CPU 0 cache中,执行的操作序列如下:
CPU0:
void foo(void) {
a = 1;
b = 1;
}
CPU1:
void bar(void) {
while (b != 1);
assert (a == 1);
}
本处解释假设不存在invalidate queue,方便理解问题),却引入了复杂性,考虑如下顺序:
cpu0执行a = 1指令,其cache中值是0,那么由于store buffer的引入,cpu0直接将a = 1写入了store buffer,不需要等待cpu1的信号响应就可以执行b = 1。
cpu0执行b = 1时,由于b在cpu0中的状态是exclusive独占(可以去参考文章看MESI协议),cpu0直接将b = 1写入了cpu 0 cache中,那么cpu1执行b != 1 成功,然后执行assert( a == 1),由于cpu0需要等到cpu1响应完invalidate才将 a= 1写入cache中(目前还在store buffer中),所以cpu1读取到的任然是0,assert失败。
从图例可以看到第六步assert失败的核心原因在于,cpu0上缓存过b,所以cpu0执行b = 1k立马写入cache,然后线程B所在的cpu1执行while(b==0)迅速跳出,然后执行 a = 1,由于此时 a 还在cpu0的store buffer中,所以导致assert失败。
解决方案:
void foo(void) {
a = 1;
smp_wmb();
b = 1;
}
smp_mb保证 b = 1执行前,先把store buffer内存刷新到cache中。CPU1 执行assert( a == 1),发现a 不在cache中,向CPU0发送read消息,CPU0的cache中存在a,所以CPU1获取到a = 1,assert成功。
x86-TSO 模型下是没有 invalidate queue 的,因此不需要读屏障,
编译器和处理器都必须遵守重排序规则。在单处理器的情况下,不需要任何额外的操作便能保持正确的顺序。但是对于多处理器来说,保证一致性通常需要增加内存屏障指令。即使编译器可以优化掉字段的访问(例如因为未使用加载到的值),编译器仍然需要生成内存屏障,就好像字段访问仍然存在一样(可以单独将内存屏障优化掉)。
内存屏障只与内存模型中的高级概念(例如 acquire 和 release)间接相关。内存屏障指令只直接控制 CPU 与其缓存的交互,以及它的写缓冲区(持有等待刷新到内存的数据的存储)和它的用于等待加载或推测执行指令的缓冲。这些影响可能导致缓存、主内存和其他处理器之间的进一步交互。
几乎所有的处理器都至少支持一个粗粒度的屏障指令(通常称为 Fence,也叫全屏障),它保证了严格的有序性:在 Fence 之前的所有读操作(load)和写操作(store)先于在 Fence 之后的所有读操作(load)和写操作(store)执行完。对于任何的处理器来说,这通常都是最耗时的指令之一(它的开销通常接近甚至超过原子操作指令)。大多数处理器还支持更细粒度的屏障指令。
LoadLoad Barrier(读读屏障)
指令 Load1; LoadLoad; Load2 保证了 Load1 先于 Load2 和后续所有的 load 指令加载数据。通常情况下,在执行预测读(speculative loads)或乱序处理(out-of-order processing)的处理器上需要显式的 LoadLoad Barrier。在始终保证读顺序(load ordering)的处理器上,这些屏障相当于无操作(no-ops)。
StoreStore Barrier(写写屏障)
指令 Store1; StoreStore; Store2 保证了 Store1 的数据先于 Store2 及后续 store 指令的数据对其他处理器可见(刷新到内存)。通常情况下,在不保证严格按照顺序从写缓冲区(store buffers)或者 缓存(caches)刷新到其他处理器或内存的处理器上,需要使用 StoreStore Barrier。
LoadStore Barrier(读写屏障)
指令 Load1; LoadStore; Store2 保证了 Load1 的加载数据先于 Store2 及后续 store 指令刷新数据到主内存。只有在乱序(out-of-order)处理器上,等待写指令(waiting store instructions)可以绕过读指令(loads)的情况下,才会需要使用 LoadStore 屏障。
StoreLoad Barrier(写读屏障)刷新写缓冲区,最耗时
指令 Store1; StoreLoad; Load2 保证了 Store1 的数据对其他处理器可见(刷新数据到内存)先于 Load2 及后续的 load 指令加载数据。StoreLoad 屏障可以防止后续的读操作错误地使用了 Store1 写的数据,而不是使用来自另一个处理器的更近的对同一位置的写。因此只有需要将对同一个位置的写操作(stores)和随后的读操作(loads)分开时,才严格需要 StoreLoad 屏障。StoreLoad 屏障通常是开销最大的屏障,几乎所有的现代处理器都需要该屏障。之所以开销大,部分原因是它需要禁用绕过缓存(cache)从写缓冲区(Store Buffer)读取数据的机制。这可以通过让缓冲区完全刷新,外加暂停其他操作来实现,这就是 Fence 的效果。一般用 Fence代替 StoreLoad Barrier ,所以事实上,执行 StoreLoad 指令同时也获得了其他三个屏障的效果,但是通过组合其他屏障通常不能获得与 StoreLoad Barrier 相同的效果。
各处理器支持的内存屏障和原子操作,
参考:
彻底搞懂内存屏障(上)_storebuffer cache_nginux的博客-CSDN博客
为什么在 x86 架构下只有 StoreLoad 屏障是有效指令? - 知乎
内存屏障Memory Barrier: a Hardware View - 知乎
面试官问我MESI如何保证内存一致性,我把这个动画甩给他_哔哩哔哩_bilibili
美团一面问我内存屏障和volatile的关系,我说了20分钟_哔哩哔哩_bilibili
https://www.jianshu.com/p/801cab30b734