随着时间的推移,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 是由很多个 Cache Line 组成的,CPU Line 是 CPU从内存读取数据的基本单位,而 CPU Line 是由各种标志(Tag)+ 数据块(Data Block)组成。
事实上,数据不光是只有读操作,还有写操作,那么如果数据写⼊ Cache 之后,内存与 Cache 相对应的数据将会不同,这种情况下 Cache 和内存数据都不⼀致了,于是我们肯定是要把 Cache 中的数据同步到内存⾥的。
问题来了,那在什么时机才把 Cache中的数据写回到内存呢?为了应对这个问题,下⾯介绍两种针对写入数据的⽅法:
保持内存与 Cache ⼀致性最简单的⽅式是,把数据同时写⼊内存和 Cache 中,这种⽅法称为写直达(Write Through)。
在这个⽅法⾥,写⼊前会先判断数据是否已经在 CPU Cache ⾥⾯了:
【优点】写直达法很直观,也很简单
【缺点】⽆论数据在不在 Cache ⾥⾯,每次写操作都会写回到内存,这样写操作将会花费⼤量的时间,⽆疑性能会受到很⼤的影响。
既然写直达由于每次写操作都会把数据写回到内存,⽽导致影响性能,于是为了要减少数据写回内存的频率,就出现了写回(Write Back)的⽅法。
在写回机制中,当发⽣写操作时,新的数据仅仅被写⼊ 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 点:
第⼀点写传播很容易就理解,当某个核⼼在 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 点:
MESI 协议其实是 4 个状态单词的开头字⺟缩写,分别是:
这四个状态来标记 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 核⼼,这在⼀定程度上减少了总线带宽压⼒。
事实上,整个 MESI 的状态可以⽤⼀个有限状态机来表示它的状态流转。还有⼀点,对于不同状态触发的事件操作,可能是来⾃本地 CPU 核⼼发出的⼴播事件,也可以是来⾃其他 CPU 核⼼通过总线发出的⼴播事件。下图即是 MESI 协议的状态图:
CPU 在读写数据的时候,都是在 CPU Cache 读写数据的,原因是 Cache 离 CPU 很近,读写性能相⽐内存⾼出很多。对于 Cache ⾥没有缓存 CPU 所需要读取的数据的这种情况,CPU 则会从内存读取数据,并将数据缓存到 Cache ⾥⾯,最后 CPU 再从 Cache 读取数据。
⽽对于数据的写⼊,CPU 都会先写⼊到 Cache ⾥⾯,然后再在找个合适的时机写⼊到内存,那就有「写直达」和「写回」这两种策略来保证 Cache 与内存的数据⼀致性:
当今 CPU 都是多核的,每个核⼼都有各⾃独⽴的 L1/L2 Cache,只有 L3 Cache 是多个核⼼之间共享的。所以,我们要确保多核缓存是⼀致性的,否则会出现错误的结果。
要想实现缓存⼀致性,关键是要满⾜ 2 点:
基于总线嗅探机制的 MESI 协议,就满⾜上⾯了这两点,因此它是保障缓存⼀致性的协议。
MESI 协议,是已修改、独占、共享、已实现这四个状态的英⽂缩写的组合。整个 MSI 状态的变更,则是根据来⾃本地 CPU 核⼼的请求,或者来⾃其他 CPU 核⼼通过总线传输过来的请求,从⽽构成⼀个流动的状态机。另外,对于在「已修改」或者「独占」状态的 Cache Line,修改更新其数据不需要发送⼴播给其他 CPU 核⼼。
整理自小林coding所著的《图解系统》,仅做学习用,侵删