写点什么

缓存一致性与内存屏障

作者:蝉沐风
  • 2022 年 7 月 27 日
  • 本文字数:13057 字

    阅读完需:约 43 分钟

缓存一致性与内存屏障

故事还得从一个矛盾说起。


摩尔定律告诉我们:大约每 18 个月会将芯片的性能提高一倍。芯片的这种飞速发展直接导致了芯片的指令执行速度与内存读取速度之间的巨大鸿沟。


举个例子,CPU 在 1 纳秒之内可以执行几十条指令,但是从内存中读取一条数据就需要花费几十纳秒。这种数量级的差异便是计算机中的一个主要矛盾:


CPU 日益增长的对数据快速读取的需要和 I/O 设备读取速度不平衡不充分的发展之间的矛盾


而 CPU 运行所需要的指令和数据都存储在低速的内存中,人们无法容忍让 CPU 这样宝贵的高速设备进行漫长的等待。


计算机科学领域的任何问题都可以通过增加一个中间层来解决。所以需要一个比内存更快的存取设备做缓冲,尽量做到和 CPU 一样快,这样就不需要每次都从低速的内存中获取数据了。


于是引入了高速缓存。

1. 高速缓存


我们已经知道为什么需要高速缓存了。那么什么是高速缓存?它为什么就比内存快?既然这么快,为什么不直接当成内存用?


别急,我一点点解释。

1.1. 什么是高速缓存 Cache

我们最熟悉的内存是一种动态随机访问存储器(Dynamic RAM,DRAM),存储器中每个存储单元由配对出现的晶体管和电容器构成,每隔一段时间,固定要对 DRAM 刷新充电一次,否则内部的数据就会消失。


而高速缓存是一种静态随机访问存储器(Static RAM,SRAM),不需要刷新电路就能保存它内部存储的数据,这就是静态的含义,因此 SRAM 的存储性能非常高!工作速度在纳秒级别,勉强能跟得上 CPU 的运算速度。


但是 SRAM 的缺点就是集成度低,相同容量的内存可以设计成较小的体积,但是 SRAM 却需要更大的体积;而且,SRAM 这玩意儿巨贵!这就是不能直接把它当内存用的原因。


越靠近 CPU 核心地带的设备越需要强悍的性能,可是容量如果太小又帮不上太大的忙。如果一个中间层(一层高速缓存)不能高效解决问题,那就多来几个中间层。目前 CPU 的解决思路一般是以量取胜,比如同时设置L1L2L3三级缓存。


在缓存容量上,通常是内存 > L3 > L2 > L1,容量越小速度越快。其中L1L2是由每个 CPU 核心独享的,L3缓存是由所有 CPU 核心共享的。CPU 的架构见下图:



需要特别说明的是,L1缓存又分为了L1d数据缓存(L1 Data)和L1i指令缓存(L1 Instruct),上图为了完整性一并画出了,本文中的高速缓存一律指数据缓存。


为了接下来方便讲解,我们把三级缓存模型简化为一级缓存模型,毕竟道理都是相通的嘛。看一下简化之后的图。


1.2. 缓存行

说完了什么是 Cache,接下来我们来看看 Cache 里装的到底是什么?


这不是废话嘛,肯定装的是数据啊。没错,是从内存中获取到的数据,但是数据的单位呢?CPU 每次只把需要的数据从内存中读取到 Cache 就行了吗?肯定不是,我们想一下,只把需要的一个数据从内存中读到 Cache,CPU 再从 Cache 中继续读这个数据进行处理,Cache 的存在完全就是多此一举,还不如直接从内存读数据呢。


所以要想让 Cache 充分发挥作用,必须让它做点“多余”的事情。因此从内存中获取数据的时候,我们把包含目标数据的一整块内存数据都放入 Cache 中。别小看这个动作,它有个科学的解释,叫做空间局部性


位置相邻的数据常常会在相近的时间内被访问


根据空间局部性原理,如果目标数据相邻的数据被访问,CPU 就不需要再从内存中获取了,这种直接从 Cache 中获取到目标数据的行为叫做“缓存命中”,极大地提高了 CPU 的工作效率。如果 Cache 里边没有,就称为 Cache Miss,CPU 需要再等待几十个指令周期从内存中把这一整块内存数据读入 Cache。


给存储“一整块内存数据”的地方起个名字,叫「缓存行」(Cache Line)。


Cache 是由缓存行组成的,缓存行是 CPU 高速缓存和内存交互的最小单元。在 X86 架构中,缓存行的大小是 64 个字节,大小和 CPU 具体型号有关。本文只关注缓存行的抽象概念,不涉及具体的缓存行大小。




接下来,终于要进入本文的正式部分了。



我一直认为,计算机的演进就是一部在挖坑和填坑之间反复横跳的发展史。对这一点的理解会随着本文的后续讲述逐渐加深。比如高速缓存 Cache 很好地解决了 CPU 与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,我来举个例子。

2. 伪共享问题

我们到目前为止说的都是 CPU 从 Cache 中 read 数据,但是总得有 write 的时候吧。既然有了 Cache,肯定就得先把值 write 到 Cache 中,再更新到内存里啊。那么,问题来了。

2.1. 什么是伪共享


数据XYZ同处于一个缓存行内,Core0Core1同时加载了该缓存行到 Cache 中,此时Core0修改了该缓存行中的XX1,如果此时Core1也想修改YY1该怎么办呢?


由于缓存行是 Cache 和内存之间交互的最小单元,所以Core0根本不知道Core1修改的是缓存中的Y还是X,所以为了防止造成并发问题,最好的办法就是让Core1中的该缓存行失效,重新加载。这就是伪共享问题。


伪共享问题的定义:当多核心修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享

2.2. 解决伪共享

既然问题是由多个变量共享一个缓存行导致的,那就让Y变量独享一个缓存行就好了。



最简单的方法就是通过代码手动进行字节填充,拿早期的LinkedTransferQueue中的部分源码举个例子,注意看注释内容:


static final class PaddedAtomicReference<T> extends AtomicReference<T> {    // 追加15个对象引用,一个对象引用占据4个字节    // 加上继承自父类的value,共64字节,正好占一个缓存行    Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;    PaddedAtomicReference(T r) {        super(r);    }}
//父类public class AtomicReference<V> implements java.io.Serializable { private volatile V value; public AtomicReference(V initialValue) { value = initialValue; }}
复制代码


此外,JDK 8 开始,提供了一个sun.misc.Contended注解来解决伪共享问题,加上这个注解的类会自动补齐缓存行。


稍微扯远了一些,我们回到上方的动图。Core0修改了缓存行中的X,我们说当前最合适的处理办法就是让Core1中的缓存行失效,否则就会出现缓存一致性问题。伪共享问题其实就是解决缓存一致性问题的副作用。只不过本文中我单独把这个问题列了出来。


为了解决缓存一致性问题,CPU 天然支持了总线锁的功能。

3. 总线锁

顾名思义就是,锁住 Bus 总线。通过处理器发出lock指令,总线接受到指令后,其他处理器的请求就会被阻塞,直到此处理器执行完成。这样,处理器就可以独占共享内存的使用。


但是,总线锁有一个非常大的缺点,一旦某个处理器获取总线锁,其他处理器都只能阻塞等待,多处理器的优势就无法发挥


于是,经过发展、优化,又产生了缓存锁。

4. 缓存锁

缓存锁:不需锁定总线,维护本处理器内部缓存和其他处理器缓存的一致性。相比总线锁,会提高 cpu 利用率。


但是缓存锁也不是万能,有些场景和情况依然必须通过总线锁才能完成。


缓存锁其实是一种实现的效果,它是通过缓存一致性协议来实现的,可能有的读者也听说过Snoopy嗅探协议,我举个例子帮助大家理解这三个概念。



假如村里有一个单人公厕,一条蜿蜒大道与公厕相连,大道旁边住着 A、B、C、D 四个人,每个人要上厕所必须经过主干道。


我们再设置一点前提,假设每个人都不想到了厕所门口的时候才知道厕所已经被人占用了。


为了合理使用厕所,保证每次只有一个人进入厕所,并且不会出现其他人在厕所门口等待的情况,ABCD 四个人聚在一起开会讨论,协商出了一条约定。


当有人去上厕所的时候,其他人在家老实呆着,不要去上厕所!


四个人纷纷拍着自己大腿叫绝。他们商议出来了一个听起来确实能解决问题,但是实际上内容非常空洞的一个协议。


因为他们不知道现在有谁正在占用厕所,更不知道谁正在前往厕所的路上。


其中 A 灵机一动,想出了一个办法。可以在每家和主干道的岔路口设置一个监测设备,当有人上厕所经过岔路口的时候监测设备就提醒其他三个人已经有人去厕所了,老实在家等着吧。


如此一来,就达成了一种给厕所添加锁的一种效果,这种效果就相当于上文提到的缓存锁。4 人商议出来的协议就相当于缓存一致性协议,A 提出来的方法实现了协议,相当于 Snoopy 嗅探。


如果大家读到这里在思考例子中的厕所究竟表示内存还是缓存行,我劝大家赶紧止住。在生活中找到和计算机科学中非常贴切的例子是非常非常困难的。这个例子只是简单说明一下缓存锁和锁存一致性协议以及 Snoopy 嗅探协议之间的关系罢了,不要深究!


自然,缓存一致性协议就是我们接下来的主角了。

5. 缓存一致性协议

每个处理器共享同一个主内存,并且都有自己的高速缓存。如果多个处理器都对同一块主内存区域进行更改,将导致各自的的缓存数据不一致。那同步到主内存时该以谁的缓存数据为准呢?


缓存一致性协议就是为了解决这个问题提出的,这类协议有MSIMESIMOSI等。


我们以应用最广泛的MESI为例进行介绍。

5.1. MESI

MESIModifiedExclusiveSharedInvalid四个单词的首字母缩写,表示缓存行的 4 种状态。


  • Modified


缓存行被对应的 CPU 核心修改之后就会处于Modified状态,并且保证该缓存行不会出现在任何其他 CPU 的缓存中。即使有,也是Invalid状态,需要从内存或其他 Cache 中重新读取。


因此,处于Modified状态的缓存行可以说是被相应 CPU 核心独占的。由于该缓存行拥有该数据的唯一最新副本,因此该缓存行最终负责将其写回内存或将其传递给其他 CPU。



  • Exclusive


Exclusive状态就非常好理解了,意味着独占、排他,和Modified状态非常类似。唯一不同的一点就是这个缓存行还没有被 CPU 核心修改,这也说明内存中的内容依然是最新的。即便如此,一旦某个缓存行处于该状态,就意味着其他 CPU 核心不能拥有该缓存行的副本。



  • Shared


处于Shared状态的缓存行意味着同时出现在了一个或多个 CPU Cache 中,且多个 CPU Cache 的缓存行和内存中的数据一致。CPU 核心不能在未与其他核心“协商”的情况下,修改其 Cache 中的该缓存行。至于什么是“协商”,下文会讲到。



  • Invalid


处于Invalid状态的缓存行不包含任何数据,只是被打上了Invalid状态的标签而已。其他 CPU 修改了缓存行,就会导致本 CPU 中的该缓存行失效为Invalid状态。当有新数据被放入 Cache 中时,会被优先放入Invalid状态的缓存行中,避免置换出其他有用的缓存导致 Cache Miss。



以上 4 种状态之间的跃迁离不开各个 CPU 核心之间的协作,比如某个数据被同时缓存在多个 CPU 核心的 Cache 中,此时这些缓存行的状态是Shared,假如Core0对缓存行做了 write 操作,为了避免缓存数据的不一致性,其他 CPU 核心需要将对应的缓存行状态设置为Invalid状态。那么其他 CPU 核心是怎么知道Core0修改了缓存行呢?换个问法,Core0怎么让其他核心知道自己修改了缓存行呢?


人有人言,兽有兽语。CPU 核心之间的沟通也有自己的一套“黑话”,称为缓存一致性消息

5.2. CPU 之间的“黑话”

消息分为请求和响应两类。


处理器在进行数据读写的时候会往总线(Bus)中发请求消息,同时每个处理器核心还会嗅探(Snoop)总线中由其他处理器发出的请求消息并在一定条件下往总线中回复响应消息。


  • Read


Read消息表示要读取某个缓存行,同时会携带目标缓存行对应的物理地址。


  • Read Response


是对Read消息的反馈,反馈的内容就是发送Read消息的 CPU 核心请求的目标缓存行。Read Response可能来自于内存,也可能来自其他 CPU 核心。


比如,如果被请求的目标缓存行不存在于任何 CPU Cache 中,那么只能从内存中获取;如果被请求的目标缓存行恰好被其中一个 CPU 修改,此时该缓存行为Modified状态,意味着该缓存行目前是最新数据,那么理应让其他同样需要该缓存行的 CPU 核心获取到该最新数据,更进一步,自然理应由该 CPU 核心把该缓存行的内容反馈给发出Read消息的 CPU 核心。



  • Invalidate


Invalidate的含义是使某个缓存行失效,拥有该缓存行的其他 CPU 核心需要删除该缓存行中的数据,并对发出Invalidate消息的核心做出反馈。


  • Invalidate Acknowledge


这就是上面提到的Invalidate消息的反馈,意味着发出此消息的 CPU 核心已经将Invalidate消息的目标缓存行中的数据清除。



如果有多个 CPU 同时发出Invalidate消息怎么办?答案是总线裁决。首先占用消息总线的 CPU 核心获胜,其他核心只能乖乖清空自己的缓存行,并向其发出Invalidate Acknowledge反馈。


  • Read Invalidate


Read Invalidate相当于Read + Invalidate,既要读取某个缓存行信息,又要让属于其他 CPU 核心的此缓存行失效。同样,Read Invalidate也需要收到反馈,只不过此反馈既包含 1 条Read Response,又包含多条(如果其他 CPU 核心也拥有目标缓存行的话)Invalidate Acknowledge


  • Writeback


Writeback消息包含要写回内存的地址和数据,通常指的是Modified状态的数据,这样 Cache 就可以根据需要弹出处于Modified状态的缓存行,以便为其他数据腾出空间。


这俩消息很简单,就不画图浪费你们的流量了。。。

5.3. MESI 状态跃迁示例

CPU 之间通过缓存一致性消息的传递,才有了缓存行在MESI四种状态之间的跃迁。



如上图,每两个状态之间都可能会发生状态越迁,是不是感觉很复杂?


如果之前的内容我给你解释地很清楚的话,就很容易想明白每个状态之间的跃迁场景了。为了不影响接下来的讲解,我把每种场景解释放在了文章最后(见附录 1),需要的读者读完文章之后可以翻阅一下(即使不看也不会影响接下来的阅读哦)。


还有一个在线的网站可以帮助你更好地理解 MESI 协议(见附录 2),你可以站在 CPU 的角度发出指令,网站以动态方式展示缓存行的状态变换,强烈建议阅读完文章之后大家试一下。


截至目前,文章都是围绕 Cache 展开的,高速缓存的引入极大地提高了计算机的整体运行效率。但在某些特殊情况下,CPU 的性能表现却是非常糟糕。

6. 不能让 CPU 闲着

考虑这么一个场景,CPU 0CPU 1 同时拥有某个缓存行,两个缓存行都处于Shared状态,CPU 0想对自己的缓存行执行 write 操作,必须先发送Invalidate消息让CPU 1中的缓存行失效。如下图所示:



由于CPU 0必须等到CPU 1反馈了Invalidate Acknowledge之后才能确保自己可以操作缓存行,所以从发出Invalidate直到收到Invalidate Acknowledge的这段时间,CPU 0一直处于闲置状态。


CPU 是何等宝贵的资源,让它闲着是不可能的,绝对不可能的!


硬件工程师为了解决这个问题,引入了Store Buffers

6.1. 引入 Store Buffers


工程师在 CPU 和 Cache 之间添加了一个中间层——Store Buffer。当CPU 0想执行 write 指令时,先把想要 write 的值写入到Store Buffer中,然后再继续执行其他任务,无需傻傻地等待CPU 1。直到CPU 1传回反馈之后,CPU 0再将Store Buffer中的最新值写入到缓存行中。


计算机的发展就是不断挖坑、填坑的过程。Store Buffers的引入解决了 CPU 闲置的问题,如果事情发展到现在就完美了该有多好,然而又引出了 3 个新问题。

6.2. Store Buffers 引起的问题 1


看一下上图左侧的代码,其中ab的初始值为 0,在大多数时候,最后的断言会为 True。


之所以说大多数时候,因为左侧的代码在某个场景下可能会出现不符合我们预期的情况(断言为 False)。如果要证明,我们只需要举出一个反例即可,因此我们进一步假设含有变量a的缓存行已经存在于CPU 1的 Cache 中,含有变量b的缓存行已经存在于CPU 0的 Cache 中。


下面我们根据引入Store Buffers之后的 CPU 架构来执行上面的代码,CPU 0 和CPU 1 的操作顺序如下图所示:



  1. CPU 0 执行 a = 1;

  2. CPU 0 首先从自己的 Cache 中查找a,发现没有;

  3. CPU 0 发送Read Invalidate消息来获取含有a的缓存行,并通知其他 CPU,“老子要用,你们都给我销毁!”;

  4. CPU 0 在Store Buffer中记录下自己想赋给a的值,即a = 1。此时 CPU 0 并不会阻塞,会继续向下执行,但是在时间线的发展上,紧接着是 CPU 1 的操作,见第 5 步;

  5. CPU 1 收到来自 CPU 0 的Read Invalidate消息,于是把自己包含a的缓存行返回给 CPU 0,并且把自己的缓存行状态设置为Invalid

  6. CPU 0 开始执行b = a + 1

  7. CPU 0 收到来自 CPU 1 的缓存行,并放到自己的缓存行中,其中a的值为 0;此时 CPU 0 的缓存行中的ab的状态都是Exclusive,因为这些缓存行都由 CPU 0 独占;

  8. CPU 0 从缓存行中读取a,此时值为 0;

  9. CPU 0 根据自己之前在Store Buffer中存放的a = 1来更新自己 Cache 中的a,设置为 1;

  10. CPU 0 在第 8 步获取的a值的基础上+ 1(这一步不需要重新从缓存行中读取数据,因为读取的动作在第 8 步中已经做了),并更新自己缓存行中的b;此时包含b的缓存行的状态为Modified

  11. CPU 0 执行断言操作,发现断言为 False。


再给大家补充一个动图:



这确实是一件非常违反直觉的事情,我们本来以为 CPU 就是完全按照代码的顺序执行的(至少最终结果应该表现地像 CPU 是完全按照代码的顺序执行的一样),我们认为b的最终结果就应该是 2。


出现这个问题的原因是CPU 0 运行过程中出现了a的两份数据拷贝,一份是在Store Buffer中,一份是在 Cache 中。为了不让软件工程师疯掉,继续保持软件代码的直观性,硬件工程师又引入了Store Forwarding来解决这个问题。

6.3. 引入 Store Forwarding

每个 CPU 在执行数据加载操作时都直接使用Store Buffer中的内容,而无需从 Cache 中获取,如下图所示。



请注意上图和原来图片的区别,上图中的Store Buffer中的数据可以直接被 CPU 读取。对应到上面的CPU 0 的操作步骤,就是第 8 步直接从Store Buffer中读取最新的a,而不是从 Cache 中读取,这样整个程序的最终断言结果就是 True!


总之,引发的第 1 个问题,硬件工程师通过引入Store Forwarding为我们解决了。

6.4. Store Buffers 引起的问题 2

在多个 CPU 并发处理情况下也可能会导致代码运行出现问题。


同样也是举一个极端一点的例子。见下图左侧的代码,其中ab的初始值为 0,进一步假设含有变量a的缓存行已经存在于CPU 1的 Cache 中,含有变量b的缓存行已经存在于CPU 0的 Cache 中。CPU 0 执行foo方法,CPU 1 执行bar方法。正常情况下,bar方法中的断言结果应该为 True。



然而,我们按照下图中的执行顺序操作一遍之后,断言却是 False!



  1. CPU 0 执行a = 1,首先从自己的 Cache 查找啊,发现没有;

  2. CPU 0 将a的新值1写入到自己的Store Buffer中;

  3. CPU 0 发送Read Invalidate消息(从发出这个消息到 CPU 1 接收到,期间又运行了非常多的步骤,见下方 GIF 图);

  4. CPU 1 执行while (b == 0) continue,发现b不在自己的Cache中,于是发送Read消息;

  5. CPU 0 执行b = 1,由于b已经存在于自己的 Cache 中了,所以直接将 Cache 中的b修改为1,并修改包含b的缓存行的状态为Modified

  6. CPU 0 收到来自第 4 步 CPU 1 发出的Read消息,由于当前自己拥有的b是最新版本的,所以 CPU 0 把含有b的缓存行返回给 CPU 1,同时修改自己的缓存行状态为Shared

  7. CPU 1 收到来自 CPU 0 的b缓存行数据,放到自己的 Cache 中,并设置为Shared状态;

  8. CPU 1 结束while循环,因为此时的b值已经是1了;

  9. CPU 1 执行assert(a == 1),由于 Cache 中的a值是0(此时还没收到来自 CPU 0 的Read Invalidate消息,因此 CPU 1 有理由认为自己的数据就是合法的),因此断言结果为 False;

  10. CPU 1 终于收到来自 CPU 0 的Read Invalidate消息了,虽然已经晚了(当然 CPU 压根不知道自己的这个消息接收的时机并不合适),但是还得按照约定把自己的a设置为Invalid状态,并且给 CPU 0 发送Invalidate Acknowledge以及Read Response反馈;

  11. CPU 0 收到 CPU 1 的反馈,利用Store Buffer中的值更新a


至此,流程全部结束,再送给大家一个 GIF。



我们分析一下结果不符合我们预期的原因。


Store Buffer的加入导致Read Invalidate的发送是一个异步操作,异步可能导致的结果就是 CPU 1 接收到 CPU 0 的Read Invalidate消息太晚了,导致在 Cache 中的实际操作顺序是b = 1,最后才是a = 1,就好像写操作被重排序了一样,这就是 CPU 的乱序执行


如果没有看懂上面一段就再看一下图片中的 CPU 0 Cache 的时间线演化。


很多人看到「乱序执行」唯恐避之不及,它当初可是为了提高 CPU 的工作效率而诞生的,而且在大多数情况下并不会导致什么错误,只是在多处理器(smp)并发执行的时候可能会出现问题,于是便有了下文。


也就是说,如果在第 5 步 CPU 0 修改b之前,我们强制让 CPU 0 先完成对a的修改就可以了。


为了解决这样的问题,CPU 提供了一些操作指令,来帮助我们避免这样的问题,就是大名鼎鼎的内存屏障(Memory Barrier,mb)。

6.5. 内存屏障

我们稍微修改一下foo方法,在b = 1之前添加一条内存屏障指令smp_mb()



多说一点,smp的全称是 Symmetrical Multi-Processing(对称多处理)技术,是指在一个计算机上汇集了一组处理器(多 CPU),各 CPU 之间共享内存子系统以及总线结构。


为什么要特意加上smp呢?因为即便现代处理器会乱序执行,但在单个 CPU 上,指令能通过指令队列顺序获取指令并执行,结果利用队列顺序返回寄存器,这使得程序执行时所有的内存访问操作看起来像是按程序代码编写的顺序执行的,因此没必要使用内存屏障(前提是不考虑编译器的优化的情况)。


内存屏障听起来很高大上,但是对于软件开发者而言其实非常简单,总结一句话就是:


在内存屏障语句之后的所有针对 Cache 的写操作开始之前,必须先把 Store Buffer 中的数据全部刷新到 Cache 中。


如果你看明白了我上面说的Store Buffer,这句话是不是贼好懂呢?换个角度再翻译一下,就是一定要保证存到 Store Buffer 中的数据有序地刷新到 Cache 中,这样就可以避免发生指令重排序了。


如何保证有序呢?


最简单的方式就是让 CPU 傻等,CPU 0 在执行第 5 步之前必须等着CPU 1给出反馈,直到清空自己的Store Buffer,然后才能继续向下执行。


啥?又让 CPU 闲着?一切让 CPU 闲置的方法都是馊主意!


还有一个办法就是让数据在Store Buffer中排队,谁先进入就必须先刷新谁,后边的必须等着!


这样一来,本来可以直接写入 Cache 的操作(比如待操作的数据已经存在于自己的 Cache 中了)也必须先存到 Store Buffer,然后依序进行刷新



应用内存屏障之后的操作步骤就不给大家再写一遍了,相信大家能够想清楚。


总之,引发的第 2 个问题,我们通过使用内存屏障解决了。

6.6. Store Buffers 引起的问题 3

Store Buffer的容量通常很小,如果 CPU 此时需要对多个数据执行 write 操作,碰巧这些数据都不在该 CPU 的 Cache 中,那么该 CPU 只能发送对应的Read Invalidate指令了,同时新数据写入Store Buffer,非常容易导致Store Buffer空间被占满。


一旦Store Buffer被占满,CPU 就只能干等着目标 CPU 完成Read Invalidate操作,并且返给自己Invalidate Acknowledge,当前 CPU 才能逐步将Store Buffer中的值刷新到 Cache,腾出空间,然后继续执行。


CPU 又又又闲下来了!所以我们肯定又得找个办法来解决这个问题。


出现这个问题的主要原因在于Invalidate Acknowledge的反馈速度太慢了!


因为 CPU 太老实了,它只有在确认自己的缓存行被设置为Invalid状态之后才会发送Invalidate Acknowledge。如果 Cache 的其他操作太频繁,“设置缓存行为 Invalid 状态”这个动作本身都会被延迟执行,更何况Invalidate Acknowledge的反馈动作呢,得等到猴年马月啊!


上面的 GIF 图中为了表现出「反馈慢」这种情况,我特意把Invalidate消息的发送速度设置地很慢,其实消息地发送速度非常快,只是 CPU 处理Invalidate消息的速度太慢了而已,望悉知。


如果不想等,想直接获取操作结果,你想到了什么?


没错,是异步!


实现方式就是再加一层消息队列——Invalidate Queues

6.7. 引入 Invalidate Queues

如下图,我们的硬件架构又升级了。在每个 CPU 的 Cache 之上,又设置了一个Invalidate Queue


这样一来,收到Invalidate消息的 CPU 核心会把Invalidate消息直接存储到Invalidate Queue中,然后立即返回Invalidate Acknowledge,不需要再等着缓存行被实际设置成Invalid状态再发送,极大地提高了反馈速度。


你可能会问,万一Invalidate Queues中的Invalidate消息最终执行失败,但是Acknowledge消息已经返回了,这该怎么办呢?


好问题!答案是,我不知道。我们就当作硬件工程师绝对不会留下这个 bug 就是了。



Invalidate Queue填了Store Buffer容量太小的坑,接下来看看它自己又挖了什么坑吧。

6.7.1. Invalidate Queue 引发的问题

这个坑比较严重,很有可能直接干翻缓存屏障,再次引发乱序执行的问题。


老样子,还是先准备一下翻车的环境。如下图,我们假设变量aCPU 0CPU 1 共享,为Shared状态;变量bCPU 0 独占,为Exclusive状态;CPU 0CPU 1 分别执行foobar方法。



我们按照下图中的执行顺序操作一遍。



  1. CPU 0 执行a = 1,因为 CPU 0 的 Cache 中已经有a了,状态为Shared,因此不能直接修改,需要发送Invalidate(不是Read Invalidate,因为自己有a)消息使其他缓存行失效;

  2. CPU 0 把试图修改的a的最新值1放入Store Buffer

  3. CPU 0 发送Invalidate消息;

  4. CPU 1 执行while(b == 0) continue;发现b不在自己的 Cache 中,于是发送Read消息来获取b

  5. CPU 1 收到来自 CPU 0 的Invalidate消息,把该消息放入Invalidate Queue中(并没有立即让a失效),等候处理,然后立刻返回Anknowledge

  6. CPU 0 收到Acknowledge消息,认为 CPU 1 已经把a值设置为Invalid了,于是放心地把Store Buffer中的数据刷新到自己的 Cache 中,此时 CPU 0 Cache 中的a1,状态为Modified;然后就可以直接越过smp_mb()内存屏障,因为现在Store Buffer中的数据已经空了,满足内存屏障的约束条件。

  7. CPU 0 执行b = 1,因为其独占了b,所以可以直接在 Cache 中修改b的值,此时b缓存行的状态为Modified

  8. CPU 0 收到来自 CPU 1 的Read消息,将修改之后的b缓存行返回,并修改自己 Cache 中的b缓存行的状态为Shared

  9. CPU 1 收到包含b的缓存行数据,放在自己的 Cache 中,此时 CPU 1 的 Cache 同时拥有了ab

  10. CPU 1 结束执行while(b == 0) continue;因为此时 CPU 1 读到的b已经是1了;

  11. CPU 1 开始执行assert(a == 1),CPU 1 从自己的 Cache 读到a0,断言为 False。

  12. CPU 1 开始处理Invalidate Queue队列,令 Cache 中的a失效,但是为时已晚!


至此流程全部结束,再上个 GIF。



问题很明显出在第 11 步,这就是臭名昭著著名的可见性问题CPU 0 修改了a的值,CPU 1 却不知道或者说知道的太晚!如果在第 11 步读取a的值之前就赶紧刷新Invalidate Queue中的消息,让a失效就好了,这样CPU 1 就不得不重新Read,得到的结果自然就是1了。


原因搞明白了,怎么解决呢?内存屏障再一次闪亮登场!

6.7.2. 内存屏障的另一个功能

上文已经解释了内存屏障的功能,再抄一遍加深印象:


1.在内存屏障语句之后的所有针对 Cache 的写操作开始之前,必须先把Store Buffer中的数据全部刷新到 Cache 中。


其实内存屏障还有另一个功能:


2.在内存屏障语句之后的所有针对 Cache 的读操作开始之前,必须先把Invalidate Queue中的数据全部作用到 Cache 中。


使用缓存屏障之后的代码就变成了这个样子:



bar方法在assert之前添加了内存屏障,意味着在获取a的值之前,所有在Invalidate Queue中的Invalidate消息必须作用到 Cache 中。


至此,我们再次用内存屏障解决了可见性问题。


问题还没有结束......

7. 读内存屏障 & 写内存屏障

内存屏障有两个功能,在foo方法中实际发挥作用的是功能 1,功能 2 并没有派上用场;同理,在bar方法中实际发挥作用的是功能 2,功能 1 并没有派上用场。于是很多不同型号的 CPU 架构(不是所有)将内存屏障功能分为了读内存屏障写内存屏障,具体如下。


  • smp_mb(全内存屏障,包含读和写全部功能)

  • smp_rmb(read memory barrier,仅包含功能 2)

  • smp_wmb(write memory barrier,仅包含功能 1)


上文已经解释地挺清楚了,因此就不再重复介绍smp_rmbsmp_wmb的作用了。直接看修改之后的代码吧。


8. 总结

计算机的演进就是一部反复挖坑、填坑的发展史。


为了解决内存和 CPU 之间速度差异过大的问题,引入了高速缓存 Cache,结果导致了缓存一致性问题;


为了达到缓存一致的效果,CPU 之间需要沟通啊,于是又设计了各种消息传递,结果消息传递导致了 CPU 的偶尔闲置;


为了不让 CPU 停下来,硬件工程师加入了写缓冲——Store Buffer,这一下子带来了 3 个问题!


第一个问题比较简单,通过引入Store Forwarding解决了;


第二个问题是操作重排序问题,我们又引入了内存屏障的第一个大招;


第三个问题是由于Store Buffer空间限制导致 CPU 又闲下来了,于是又设计了Invalidate Queues,然后又导致了乱序执行和可见性问题;


通过使用内存屏障的全部大招终于解决了乱序执行和可见性问题,又引出了大招伤害性过强的问题,于是又拆分成了更细粒度的读屏障写屏障。。。。。。

9. 后记

问题其实还有很多,比如各种不同 CPU 结构是怎么实现内存屏障的?可想而知,每个人都有每个人的想法,不同 CPU 的实现指定也不一样,甚至可能拆分地更细或者更粗。不过这些与大部分软件开发者(包括我)都没有什么关系了,更多问题还是留给芯片开发者以及操作系统开发者吧。


不要纠结于具体的实现细节,把文章中的大部分搞懂已经能帮助我们理解很多问题了。如果还想知道的更多,可以看看附录 3 中的第 1 篇文章。

10. 附录 1——MESI 跃迁场景解析


(a):通过Writeback消息把被修改过的缓存行刷新至内存,但是 CPU 的 Cache 仍然保留该数据;


(b):CPU 修改了只保存在当前 Cache 中的缓存行;


(c):CPU 收到了Read Invalidate消息,该消息的目标正是当前处于 M 状态(被修改了)的缓存行。CPU 不得不使自己的缓存行失效,并把该缓存行数据携同Read Response以及Invalidate Acknowledge消息返回;


(d):CPU 执行了一个原子操作,该操作包含读和写两个子操作,并且不可分割。CPU 首先发送Read Invalidate消息,收到Read Response消息之后立刻对数据进行更新,至此便完成了该原子操作。


(e):和 d 大致相同。CPU 执行了一个原子操作,该操作包含读和写两个子操作,并且不可分割。CPU 首先发送Invalidate消息,收到Invalidate Acknowledge消息之后立刻对数据进行更新,至此便完成了该原子操作。


(f):当前 CPU 修改了一个缓存行数据,接着其他 CPU 核心对当前 CPU 的该缓存行发出Read消息,当前 CPU 将该缓存行数据随Read Response消息反馈给其他 CPU 核心。至于该过程会不会涉及到缓存行数据刷新到内存,那就不一定了。


(g):当前 CPU 独占了一个未经修改的缓存行,其他 CPU 对当前 CPU 的该缓存行发出 Read 消息,当前 CPU 将该缓存行随Read Response消息反馈给其他 CPU,并将缓存行的状态由Exclusive改为Shared


(h):多个 CPU 共享某个缓存行,其中一个 CPU 对其他 CPU 发出Invalidate消息,该 CPU 收到其他所有拥有该缓存行的 CPU 的Invalidate Acknowledge消息之后,将该缓存行状态切换为Exclusive;或者其他 CPU 自己清空了该缓存行(比如为其他数据腾出空间)导致该 CPU 独占该缓存行,同样会发生这种状态转换。


(i):其他 CPU 对当前 CPU 独占的一个缓存行发出一个Read Invalidate消息,当前 CPU 将该缓存行设置为Invalid,并发送Read Response以及Invalidate Acknowledge反馈;


(j):CPU 对不在自己 Cache 的一个数据进行写操作,因此发出Read Invalidate消息,收到一条Read Response(可能来自其他 Cache,也可能来自内存)以及所有拥有该缓存行的 CPU 的Invalidate Acknowledge反馈(可能压根没有)之后,缓存行被当前 CPU 独占;


(k):CPU 读取某个自己 Cache 中不存在的数据,于是发出Read消息,收到Read Response(该消息一定来自于其他 CPU)之后,缓存行的状态由Invalid变为了Shared


(l):当前 CPU 和其他 CPU 共享了一个缓存行,突然有一个其他 CPU 向当前 CPU 发来一条Invalidate消息,当前缓存行只能默默把自己的缓存行设置为Invalidate,并回复Invalidate Acknowledge

11. 附录 2——MESI 在线网站使用

地址:https://www.scss.tcd.ie/Jeremy.Jones/VivioJS/caches/MESIHelp.htm



见上图,网站主要分为 3 部分


  1. 内存数据


内存中保存了 4 个数据,初始值为0,地址分别为a0a1a2a3


  1. 高速缓存


显示 CPU 缓存的变量数据和 MESI 协议状态。该高速缓存能容纳 2 个缓存行数据,所有缓存行的初始状态为I(Invalid)。


  1. CPU 核心


共有 3 个 CPU,每个 CPU 都有各自的 Cache,CPU 操作分为「读」和「写」,这部分是我们可以手动操作的部分。


举个例子:


  1. CPU0 执行read a0,于是通过各种 bus 总线将内存中地址为a0的数据读入缓存行内,由于目前只有 CPU0 独占该缓存行,所以状态变为Exclusive

  2. CPU1 执行read a0,又通过各种 bus 总线将a0的数据读到自己的缓存行内,此时 CPU0 和 CPU1 的缓存行都变为Shared状态;

  3. CPU1 执行write a0,将地址为a0的数据+1 后写回内存,同时向 CPU0 发出Invalidate信号,导致 CPU0 将其缓存行置为Invalid状态;此时 CPU1 独占缓存行,因此缓存行为Exclusive状态;

  4. CPU1 执行read a1,类似第 1 步,通过各种 bus 总线将内存中地址为a1的数据读入缓存行内,由于目前只有 CPU1 独占该缓存行,所以状态为Exclusive;此时 CPU1 的 Cache 被占满;

  5. 最后 CPU1 执行read a2,由于 CPU1 的 Cache 已经被占满了,只能弹出a0,存入a2,此时a2状态为Exclusive


12. 附录 3——参考文献

[1] Paul E. McKenney. Memory Barriers: a Hardware View for Software Hackers.


[2] barrier和smp_mb


[3] 内存屏障和volatile语义


[4] 解密内存屏障


[5] MESI在线网站


[6] 看懂这篇,才能说了解并发底层技术


[7] Why Memory Barriers中文翻译(下)


发布于: 2022 年 07 月 27 日阅读数: 92
用户头像

蝉沐风

关注

个人网站 | https://www.chanmufeng.com 2021.05.14 加入

我是蝉沐风,一个让你沉迷于技术的讲述者

评论 (2 条评论)

发布
用户头像
兄弟,这些插图是用什么工具画的
2022 年 08 月 01 日 16:52
回复
静态图使用drawio,里边有个草图的按钮;GIF是PPT动画,做完之后导出的
2022 年 08 月 01 日 17:01
回复
没有更多了
缓存一致性与内存屏障_volatile_蝉沐风_InfoQ写作社区