10 张图打开 CPU 缓存一致性的大门


前言

直接上,很少 BB 了。程序员


正文

CPU Cache 的数据写入

随着时间的推移,CPU 和内存的访问性能相差愈来愈大,因而就在 CPU 内部嵌入了 CPU Cache(高速缓存),CPU Cache 离 CPU 核心至关近,所以它的访问速度是很快的,因而它充当了 CPU 与内存之间的缓存角色。缓存

CPU Cache 一般分为三级缓存:L1 Cache、L2 Cache、L3 Cache,级别越低的离 CPU 核心越近,访问速度也快,可是存储容量相对就会越小。其中,在多核心的 CPU 里,每一个核心都有各自的 L1/L2 Cache,而 L3 Cache 是全部核心共享使用的。微信

咱们先简单了解下 CPU Cache 的结构,CPU Cache 是由不少个 Cache Line 组成的,CPU Line 是 CPU 从内存读取数据的基本单位,而 CPU Line 是由各类标志(Tag)+ 数据块(Data Block)组成,你能够在下图清晰的看到:oop

咱们固然指望 CPU 读取数据的时候,都是尽量地从 CPU Cache 中读取,而不是每一次都要从内存中获取数据。因此,身为程序员,咱们要尽量写出缓存命中率高的代码,这样就有效提升程序的性能,具体的作法,你能够参考我上一篇文章「如何写出让 CPU 跑得更快的代码?」性能

事实上,数据不光是只有读操做,还有写操做,那么若是数据写入 Cache 以后,内存与 Cache 相对应的数据将会不一样,这种状况下 Cache 和内存数据都不一致了,因而咱们确定是要把 Cache 中的数据同步到内存里的。spa

问题来了,那在什么时机才把 Cache 中的数据写回到内存呢?为了应对这个问题,下面介绍两种针对写入数据的方法:.net

  • 写直达(Write Through线程

  • 写回(Write Back3d

写直达

保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和 Cache 中,这种方法称为写直达(Write Throughcode

在这个方法里,写入前会先判断数据是否已经在 CPU Cache 里面了:

  • 若是数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面;

  • 若是数据没有在 Cache 里面,就直接把数据更新到内存里面。

写直达法很直观,也很简单,可是问题明显,不管数据在不在 Cache 里面,每次写操做都会写回到内存,这样写操做将会花费大量的时间,无疑性能会受到很大的影响。

写回

既然写直达因为每次写操做都会把数据写回到内存,而致使影响性能,因而为了要减小数据写回内存的频率,就出现了写回(Write Back)的方法

在写回机制中,当发生写操做时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才须要写到内存中,减小了数据写回内存的频率,这样即可以提升系统的性能。

那具体如何作到的呢?下面来详细说一下:

  • 若是当发生写操做时,数据已经在 CPU Cache 里的话,则把数据更新到 CPU Cache 里,同时标记 CPU Cache 里的这个 Cache Block 为脏(Dirty)的,这个脏的标记表明这个时候,咱们 CPU Cache 里面的这个 Cache Block 的数据和内存是不一致的,这种状况是不用把数据写到内存里的;

  • 若是当发生写操做时,数据所对应的 Cache Block 里存放的是「别的内存地址的数据」的话,就要检查这个 Cache Block 里的数据有没有被标记为脏的,若是是脏的话,咱们就要把这个 Cache Block 里的数据写回到内存,而后再把当前要写入的数据,写入到这个 Cache Block 里,同时也把它标记为脏的;若是 Cache Block 里面的数据没有被标记为脏,则就直接将数据写入到这个 Cache Block 里,而后再把这个 Cache Block 标记为脏的就行了。

能够发现写回这个方法,在把数据写入到 Cache 的时候,只有在缓存不命中,同时数据对应的 Cache 中的 Cache Block 为脏标记的状况下,才会将数据写到内存中,而在缓存命中的状况下,则在写入后 Cache 后,只需把该数据对应的 Cache Block 标记为脏便可,而不用写到内存里。

这样的好处是,若是咱们大量的操做都可以命中缓存,那么大部分时间里 CPU 都不须要读写内存,天然性能相比写直达会高不少。


缓存一致性问题

如今 CPU 都是多核的,因为 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心的缓存一致性(Cache Coherence 的问题,若是不能保证缓存一致性的问题,就可能形成结果错误。

那缓存一致性的问题具体是怎么发生的呢?咱们以一个含有两个核心的 CPU  做为例子看一看。

假设 A 号核心和 B 号核心同时运行两个线程,都操做共同的变量 i(初始值为 0 )。

这时若是 A 号核心执行了 i++ 语句的时候,为了考虑性能,使用了咱们前面所说的写回策略,先把值为 1 的执行结果写入到 L1/L2 Cache 中,而后把 L1/L2 Cache 中对应的 Block 标记为脏的,这个时候数据其实没有被同步到内存中的,由于写回策略,只有在 A 号核心中的这个 Cache Block 要被替换的时候,数据才会写入到内存里。

若是这时旁边的 B 号核心尝试从内存读取 i 变量的值,则读到的将会是错误的值,由于刚才 A 号核心更新 i 值还没写入到内存中,内存中的值还依然是 0。这个就是所谓的缓存一致性问题,A 号核心和 B 号核心的缓存,在这个时候是不一致,从而会致使执行结果的错误。

那么,要解决这一问题,就须要一种机制,来同步两个不一样核内心面的缓存数据。要实现的这个机制的话,要保证作到下面这 2 点:

  • 第一点,某个 CPU 核内心的 Cache 数据更新时,必需要传播到其余核心的 Cache,这个称为写传播(Wreite Propagation

  • 第二点,某个 CPU 核内心对数据的操做顺序,必须在其余核心看起来顺序是同样的,这个称为事务的串形化(Transaction Serialization

第一点写传播很容易就理解,当某个核心在 Cache 更新了数据,就须要同步到其余核心的 Cache 里。

而对于第二点事务的串形化,咱们举个例子来理解它。

假设咱们有一个含有 4 个核心的 CPU,这 4 个核心都操做共同的变量 i(初始值为 0 )。A 号核心先把 i 值变为 100,而此时同一时间,B 号核心先把 i 值变为 200,这里两个修改,都会「传播」到 C 和 D 号核心。

那么问题就来了,C 号核心先收到了 A 号核心更新数据的事件,再收到 B 号核心更新数据的事件,所以 C 号核心看到的变量 i 是先变成 100,后变成 200。

而若是 D 号核心收到的事件是反过来的,则 D 号核心看到的是变量 i 先变成 200,再变成 100,虽然是作到了写传播,可是各个 Cache 里面的数据仍是不一致的。

因此,咱们要保证 C 号核心和 D 号核心都能看到相同顺序的数据变化,好比变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串形化。

要实现事务串形化,要作到 2 点:

  • CPU 核心对于 Cache 中数据的操做,须要同步给其余 CPU 核心;

  • 要引入「锁」的概念,若是两个 CPU 核内心有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。

那接下来咱们看看,写传播和事务串形化具体是用什么技术实现的。


总线嗅探

写传播的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其余核心。最多见实现的方式是总线嗅探(Bus Snooping

我仍是之前面的 i 变量例子来讲明总线嗅探的工做机制,当 A 号 CPU 核心修改了 L1 Cache 中 i 变量的值,经过总线把这个事件广播通知给其余全部的核心,而后每一个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在本身的 L1 Cache 里面,若是 B 号 CPU 核心的 L1 Cache 中有该数据,那么也须要把该数据更新到本身的 L1 Cache。

能够发现,总线嗅探方法很简单, CPU 须要每时每刻监听总线上的一切活动,可是无论别的核心的 Cache 是否缓存相同的数据,都须要发出一个广播事件,这无疑会加剧总线的负载。

另外,总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其余 CPU 核心知道,可是并不能保证事务串形化。

因而,有一个协议基于总线嗅探机制实现了事务串形化,也用状态机机制下降了总线带宽压力,这个协议就是 MESI 协议,这个协议就作到了 CPU 缓存一致性。


MESI 协议

MESI 协议实际上是 4 个状态单词的开头字母缩写,分别是:

  • Modified,已修改

  • Exclusive,独占

  • Shared,共享

  • Invalidated,已失效

这四个状态来标记 Cache Line 四个不一样的状态。

「已修改」状态就是咱们前面提到的脏标记,表明该 Cache Block 上的数据已经被更新过,可是尚未写到内存里。而「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不能够读取该状态的数据。

「独占」和「共享」状态都表明 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。

「独占」和「共享」的差异在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其余 CPU 核心的 Cache 没有该数据。这个时候,若是要向独占的 Cache 写数据,就能够直接自由地写入,而不须要通知其余 CPU 核心,由于只有你这有这个数据,就不存在缓存一致性的问题了,因而就能够随便操做该数据。

另外,在「独占」状态下的数据,若是有其余核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。

那么,「共享」状态表明着相同的数据在多个 CPU 核心的 Cache 里都有,因此当咱们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向全部的其余 CPU 核心广播一个请求,要求先把其余核心的 Cache 中对应的 Cache Line 标记为「无效」状态,而后再更新当前 Cache 里面的数据。

咱们举个具体的例子来看看这四个状态的转换:

  1. 当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心本身的 Cache 里面,此时其余 CPU 核心的 Cache 没有缓存该数据,因而标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;

  2. 而后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其余 CPU 核心,因为 A 号 CPU 核心已经缓存了该数据,因此会把数据返回给 B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,而且其 Cache 中的数据与内存也是一致的;

  3. 当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向全部的其余 CPU 核心广播一个请求,要求先把其余核心的 Cache 中对应的 Cache Line 标记为「无效」状态,而后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了。

  4. 若是 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,因为此时的 Cache Line 是「已修改」状态,所以不须要给其余 CPU 核心发送消息,直接更新数据便可。

  5. 若是 A 号 CPU 核心的 Cache 里的 i 变量对应的  Cache Line 要被「替换」,发现  Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存。

因此,能够发现当 Cache Line 状态是「已修改」或者「独占」状态时,修改更新其数据不须要发送广播给其余 CPU 核心,这在必定程度上减小了总线带宽压力。

事实上,整个 MESI 的状态能够用一个有限状态机来表示它的状态流转。还有一点,对于不一样状态触发的事件操做,多是来自本地 CPU 核心发出的广播事件,也能够是来自其余 CPU 核心经过总线发出的广播事件。下图便是 MESI 协议的状态图:

MESI 协议的四种状态之间的流转过程,我汇总成了下面的表格,你能够更详细的看到每一个状态转换的缘由:


总结

CPU 在读写数据的时候,都是在 CPU Cache 读写数据的,缘由是 Cache 离 CPU 很近,读写性能相比内存高出不少。对于 Cache 里没有缓存 CPU 所须要读取的数据的这种状况,CPU 则会从内存读取数据,并将数据缓存到 Cache 里面,最后 CPU 再从 Cache 读取数据。

而对于数据的写入,CPU 都会先写入到 Cache 里面,而后再在找个合适的时机写入到内存,那就有「写直达」和「写回」这两种策略来保证 Cache 与内存的数据一致性:

  • 写直达,只要有数据写入,都会直接把数据写入到内存里面,这种方式简单直观,可是性能就会受限于内存的访问速度;

  • 写回,对于已经缓存在 Cache 的数据的写入,只须要更新其数据就能够,不用写入到内存,只有在须要把缓存里面的脏数据交换出去的时候,才把数据同步到内存里,这种方式在缓存命中率高的状况,性能会更好;

当今 CPU 都是多核的,每一个核心都有各自独立的 L1/L2 Cache,只有 L3 Cache 是多个核心之间共享的。因此,咱们要确保多核缓存是一致性的,不然会出现错误的结果。

要想实现缓存一致性,关键是要知足 2 点:

  • 第一点是写传播,也就是当某个 CPU 核心发生写入操做时,须要把该事件广播通知给其余核心;

  • 第二点是事物的串行化,这个很重要,只有保证了这个,次啊能保障咱们的数据是真正一致的,咱们的程序在各个不一样的核心上运行的结果也是一致的;

基于总线嗅探机制的 MESI 协议,就知足上面了这两点,所以它是保障缓存一致性的协议。

MESI 协议,是已修改、独占、共享、已实现这四个状态的英文缩写的组合。整个 MSI 状态的变动,则是根据来自本地 CPU 核心的请求,或者来自其余 CPU 核心经过总线传输过来的请求,从而构成一个流动的状态机。另外,对于在「已修改」或者「独占」状态的 Cache Line,修改更新其数据不须要发送广播给其余 CPU 核心。


说几句

前几个星期建的技术交流群,没想到很快就满 500 人了,群里的大牛真的多,你们交流都很踊跃,也有不少热心分享和回答问题的小伙伴。

不过不要紧,小林最近又新建了技术交流群,相信这里是你交朋友好地方,也是你上班划水的好入口。

准备入冬了,一块儿来抱团取暖吧,群满 100、200、300、500 人,小林都会发红包的,赶快来吧,加群方式很简单,扫码下方二维码,回复「加群」。

哈喽,我是小林,就爱图解计算机基础,若是以为文章对你有帮助,欢迎分享给你的朋友,也给小林点个「在看」,这对小林很是重要,谢谢大家,给各位小姐姐小哥哥们抱拳了,咱们下次见!


推荐阅读

如何写出让 CPU 跑得更快的代码?

读者问:小林你的 500 张图是怎么画的?

本文分享自微信公众号 - 小林coding(CodingLin)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。