CPU Cache的数据写入
CPU和内存的访问性能越差越大,于是在CPU内部嵌入CPU Cache(高速缓存)。
CPU Cache由Cache Line组成,Cache Line由头标志Tag+数据块Data Block组成。
如果数据写入Cache,内存和Cache相对应的数据将不同,需要同步,什么时机才把Cache中的数据写回到内存呢?
两种针对写入数据的方法:写直达(Write Through),写回(Write Back)
写直达
保存内存与Cache一致性最简单的方式,把数据同时写入内存和Cache中
写入前会先判断是否已经在CPU Cache里面:
如果数据已经在Cache里面,先将数据更新到Cache里面,再写入到内存里面;
如果数据没有在Cache里面,就直接把数据更新到内存里面。
写回
既然写直达由于每次操作都会把数据写回内存,导致影响性能,为了减少数据写回内存的频率,出现了写回方法。
当发生写操作时,新的数据仅仅被写入到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中的Cache Block为脏标记的情况下,才会将数据写到内存中。而在缓存命中情况下,只需要把该数据对应的Cache Block标记为脏即可。
好处是,如果大量操作可以命中缓存,大部分时间里的CPU都不需要写入内存,性能就会提升。
为什么缓存没命中,还要定位Cache Block?此时要判断数据即将写入到Cache Blcok 里的位置,是否被[其他数据]占了此位置,如果这个[其他数据]是脏数据,就要帮忙把它写回到内存。
缓存一致性问题
L1/L2 Cache是多个核心各自独有的,就会带来多核心的缓存一致性(Cache Coherence)的问题。
A B两个核心,A执行i++语句,为了考虑性能使用写回策略,先把值为1的执行结果写入到L1 L2Cache,然后把L1/L2 Cache标记为脏的,这时候其实没有被同步到内存,因为只有Cache Block要被替换的时候,数据才会写入到内存里。
如果此时B核心尝试从内存读取i的值,则是错误的,因为A号核心更新i值还没写入到内存,内存中的值依然是0。这个就是所谓的缓存一致性问题,A号核心和B号核心的缓存,在这个时候是不一致的,从而会导致执行结果的错误。
同步:
第一:某个CPU核心的Cache数据更新时,必须传播到其他核心的Cache,称为写传播(Write Propagation)
第二:某个CPU核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,称为事务的串行化(Transaction Serialization),要做到两点:CPU核心对于Cache中数据的操作需要同步给其他核心;要引入锁概念,如果两个CPU核心里有相同数据的Cache,那么对于这个Cache数据的更新,只有拿到了锁,才能进行对应的数据更新。
总线嗅探
写传播:当某个CPU核心更新了Cache中的数据,要把该时间广播通知到其他核心。最常见实现方式是总线嗅探(Bus Snooping)。
A号CPU核心修改了L1 Cache中i变量的值,通过总线把这个时间广播通知给其他所有核心,然后每个CPU核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的L1 Cache里面,如果B号CPU核心的L1Cache中有该数据,那么也需要把该数据更新到自己的L1 Cache。
总线嗅探不管别的核心的Cache是否缓存相同的数据,都需要发出一个广播事件,加重总线负载。
另外,总线嗅探只是保证了某个CPU核心的Cache会更新数据这个事件被其他知道,但不能保证事务串行化。
于是,有一个协议基于总线嗅探机制实现了事务串行化,也用状态机机制降低了总线带宽压力,这个协议就是MESI协议,这个协议就做到了CPU缓存一致性。
MESI协议
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里面的数据。
所以当Cache Line状态是[已修改]和[独占]状态时,修改更新其数据不需要发送广播给其他CPU核心,这在一定程度上减少了总线带宽压力。
总结
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 核心。