前言
- 参考/导流:
小林coding-2.4 CPU 缓存一致性 - 学习意义
- 底层基础知识,了解CPU执行过程,让上层编码有效
- 并发控制底层设计思维(对比 MySQL的并发控制)、更好地去理解JUC的锁、volatile以及JMM
- 架构层面的一致性保证问题,由CPU协调单位上升至线程、进程、机器
- 相关说明
该篇博文是个人阅读的重要梳理,仅做简单参考,详细请阅读小林coding的原文!
四、CPU缓存一致性
CPU Cache 和 内存不一致性
程序先加载至内存中,CPU执行程序需要先读取缓存Cache,若无该数据(内存地址)则读取内存。而计算机中使用 数据 是使用它的信息,可以存在不同地方,一份数据可存在于 Cache和内存,对于读
来说,未改变信息的状态【只需保证写的一致性,则读就会一致】,无论它在Cache,还是内存,它们是一致性的。而对于写
来说,则会改变数据的状态,此时则会导致 Cache的数据是新数据,内存由于距离远而是旧数据。此时,则产生了 缓存 和 内存 不一致
的问题。对于CPU的缓存和内存不一致性的解决办法有:
- 写直达:当缓存更新数据之后,直接写到内存,随时保证缓存和内存的一致性。问题则是加大了写的开销。如果单核情况下,CPU读数据优先从Cache读,本来该数据缓存已有该数据,无需从内存加载,随时保证缓存和内存一致性的必要性也不大。因此,只需考虑当Cache被替换,不再命中时,需要再从内存拿数据时,才更新cache的数据至内存即可。那么对于多核呢?多核则是后续Cache间来保证一致性的讨论。
- 写回:在写直达的基础,写回则是当CacheBlock被替换,需要再从内存拿数据时才去更新至内存【而cacheblock被替换时也不一定是被更改过的,此时则需要去标记该数据是否为脏,若脏则需要写回内存】。这样就会减少由Cache写至内存的频率。增大效率。
Cache的读写策略
写直达
保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和 Cache 中,这种方法称为写直达(Write Through)。
写回
直达由于每次写操作都会把数据写回到内存,而导致影响性能,于是为了要减少数据写回内存的频率,就出现了写回(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 Block 标记为脏。
总结一下Cache的读写策略
Cache写命中(Write Hit)时的策略
-
写直达 (Write Through)
CPU向Cache写数据时,Memory中的内容也将被同步更新。好处是可以保证Cache和Memory的一致性,坏处是影响运行速度。【对比CAP理论,程序设计总是 性能和可用之间做平衡】
-
写回(Write Back)
CPU向Cache写数据时,Memory中的内容将不立刻做同步更新,只做Dirty标记。当Cache块被换出时,才将改动同步到Memory中。好处是保证了程序运行速度,坏处是存在Cache和Memory的不一致性。
Cache写缺失(Write Miss)时的策略
写失效(write miss)即所要写的地址不在cache中。
- Write allocate
把要写的数据载入到Cache中,写Cache,然后再通过flush方式写入到内存中 - No write allocate
直接把要写的数据写入到内存中。
Cache读策略
- Read through
直接从内存中读取数据 - Read allocate
先把数据读取到Cache中,再从Cache中读数据。
多核心的缓存间不一致性
问题引入
现在 CPU 都是多核的,由于 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心的缓存一致性(Cache Coherence) 的问题,如果不能保证缓存一致性的问题,就可能造成结果错误。【对比 分布式架构,由核心 上升至 机器;内部通信 由 总线,到 网络通信】
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G5UseOB6-1671416925411)(https://s3-us-west-2.amazonaws.com/secure.notion-static.com/9f148b27-7234-44c3-b2db-4f9439f0ac69/Untitled.png)]
这时如果 A 号核心执行了 i++
语句的时候,为了考虑性能,使用了我们前面所说的**写回策略【**若采用写直达,时刻保证内存的数据最新,是否也会存在该问题?因为在写至内存之前,另外的核心也可能去读内存?也需要锁去保证 顺序性】,先把值为 1
的执行结果写入到 L1/L2 Cache 中,然后把 L1/L2 Cache 中对应的 Block 标记为脏的,这个时候数据其实没有被同步到内存中的,因为写回策略,只有在 A 号核心中的这个 Cache Block 要被替换的时候,数据才会写入到内存里。
如果这时旁边的 B 号核心尝试从内存读取 i 变量的值,则读到的将会是错误的值,因为刚才 A 号核心更新 i 值还没写入到内存中,内存中的值还依然是 0。这个就是所谓的缓存一致性问题,A 号核心和 B 号核心的缓存,在这个时候是不一致,从而会导致执行结果的错误。
解决以上问题,则需要保证以下两点:
- 写传播,当一个核心更新数据,能通知到其他核心。【手段方式,对比RPC中的心跳检测机制,】
- 串行化,保证事务的串行化,让核心执行事务是顺序性的。【目标,手段通过锁—】
要实现事务串行化,要做到 2 点:
- CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
- 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。
具体实现
- 总线嗅探【类似心跳检测,rpc发消息通知,结合着 分布式事务\RPC通信对比】
- MESI协议【对比共识算法、数据一致性算法以及MySQL的ACID特性】
总线嗅探
写传播的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心【时刻监听广播事件】。最常见实现的方式是总线嗅探(Bus Snooping)。
- 广播
- 监听
问题点:
- CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件。
- 总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串行化。
MESI协议
在总线嗅探时讨论,对于发广播事件的CPU核心无论别的核心是否用该数据都发生,对于其他CPU核心需要时刻监听广播通知。针对该两种问题,可以去细粒度标记数据 Cacheline的状态,来减少不必要的开销浪费。
MESI则通过以下四个状态来标记 Cache Line 四个不同的状态,来更好地进行广播传播,保证同步以及 实现 串行化。
- Modified,已修改
- Exclusive,独占
- Shared,共享
- Invalidated,已失效
已修改状态,代表该 Cache Block 上的数据已经被更新过(脏标记),但没有写到内存里。
已失效状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。
「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。
「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。
状态转换
具体示例
- 当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;
- 然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;
- 当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了。
- 如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。
- 如果 A 号 CPU 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存。