当前位置: 欣欣网 > 码农

万字详解缓存一致性协议与内存屏障(漫画风)

2024-08-27码农

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

摩尔定律告诉我们:大约每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的解决思路一般是以量取胜,比如同时设置 L1 L2 L3 三级缓存。

在缓存容量上,通常是 内存 > L3 > L2 > L1 ,容量越小速度越快。其中 L1 L2 是由每个CPU核心独享的, L3 缓存是由所有CPU核心共享的。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. 什么是伪共享

伪共享问题

数据 X Y Z 同处于一个缓存行内, Core0 Core1 同时加载了该缓存行到Cache中,此时 Core0 修改了该缓存行中的 X X1 ,如果此时 Core1 也想修改 Y Y1 该怎么办呢?

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

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

2.2. 解决伪共享

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

缓存行填充

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

staticfinal classPaddedAtomicReference<TextendsAtomicReference<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 classAtomicReference<Vimplementsjava.io.Serializable{
privatevolatile V value;
publicAtomicReference(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. 缓存一致性协议

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

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

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

5.1. MESI

MESI Modified Exclusive Shared Invalid 四个单词的首字母缩写,表示 缓存行 的4种状态。

  • Modified

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

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

    Modified的缓存行
  • Exclusive

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

    Exclusive缓存行
  • Shared

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

    Shared缓存行
  • Invalid

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

    Invalid缓存行

    以上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核心。

    Read Response
  • Invalidate

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

  • Invalidate Acknowledge

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

    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 四种状态之间的跃迁。

    MESI状态跃迁

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

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

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

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

    6. 不能让CPU闲着

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

    CPU闲下来了

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

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

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

    6.1. 引入Store Buffers

    Store Buffer

    工程师在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

    看一下上图左侧的代码,其中 a b 的初始值为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 的缓存行中的 a b 的状态都是 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 Forwarding

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

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

    6.4. Store Buffers引起的问题2

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

    同样也是举一个极端一点的例子。见下图左侧的代码,其中 a b 的初始值为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

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

    6.7.1. Invalidate Queue引发的问题

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

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

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

    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中的 a 1 ,状态为 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同时拥有了 a b

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

    11. CPU 1 开始执行 assert(a == 1) ,CPU 1 从自己的Cache读到 a 0 ,断言为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_rmb smp_wmb 的作用了。直接看修改之后的代码吧。

    8. 总结

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

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

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

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

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

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

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

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

    ·················END·················

    用官方一半价格的钱,用跟官方 ChatGPT4.0 一模一样功能的工具。

    国内直接使用ChatGPT4o:

    谷歌浏览器直接使用:https://www.nezhasoft.cn

    1. 无需魔法,同时支持手机、电脑

    2. 个人独享

    3. ChatGPT4o mini永久免费

    4. 支持Copilot、DALLE AI绘画、上传文件等

    长按识别下方二维码,备注ai,发给你

    回复gpt,获取ChatGPT4o直接使用地址

    点击阅读原文,国内直接使用ChatGpt4o