MESI原理
由于 CPU 的运算速度远远的超过了内存的 IO 速度,因此在它和内存间又加了一层高速缓存,引入了缓存就必然带来一致性的问题。来看看这样的语句执行起来会是什么样的效果。 CPU b 先加载了 n=1,然后cpuA也加载了n,还进行了n+1的操作,这时候 CPU b 再去获取 n 的值。由于高速缓存中已经有值了,所以他拿到的还是n=1,产生了不一致的情况。 CPU 厂商为了解决这样的情况,引入了缓存一致性的协议。常见的比如说MESI,它有四个状态对应着修改、独占、共享、失效,而这四种状态又能量转换形成 16 种情况,这其实就是一个状态机。这个状态机看得我都头大,其实我们没有必要去了解得很透彻,继续用刚刚的例子走一遍流程,看看它是如何保证一致性的。
CPU b 先获取 n 的值,这时候它是独占状态,然后 CPU a 在获取 n 的时候,通过总线地址冲突检测到 b 有这个值,于是向 b 请求这个值,这时候 n 变成了共享状态。当 CPU a 要修改 n 的时候,会通过总线发出失效命令,让 b 的高速缓存对应的 n 的状态变为失效。收到 ACK 后让本地的 n 变为独占状态才能够修改,修改完毕后, n 的值变成了 2 状态,变成了已修改。这时候 b 再去获取 n 的值,发现自己是失效的。需要向 a 请求,两边的 n 又回到了共享状态,通过 MESI 保证了缓存的一致性。但是你没发现一个问题, a 每次都需要修改,都要等待 b 的ACK。这是一个非常耗时的操作,会严重的影响 CPU 的利用率,因此引入了store buffer,有了它 CPU 就可以直接修改共享状态的变量。修改后扔到store buffer里,由store buffer来等待其他 CPU 的 ACK 确认。收到确认后就会将变量值写入本地缓存,这样 CPU a 就可以继续去做其他的事情了。但是存储缓存其实容量很小,但它满了之后 a 又得停在那等待了,因此又引入了失效队列,它只需要把失效消息投到 CPU 的失效队列中,就能得到 ACK 消息。其他的 CPU 可能很忙,但是他闲下来就会去消化队列里的时效消息。
我们常说的指令重排,一部分是因为编译器的优化,最重要的一点, CPU 的指令乱序执行就是这两个队列造成的。假设有两条语句,先修改c,再修改d,结果 d 先收到了a,C,k, d 优先进入了高速缓存,这样就像是颠倒了顺序,这样的指令重排它也会带来可见性的问题。
你看,为了速度我们引入了高速缓存,但是为了一致性,我们又引入了MESI。结果速度又慢下来了,又引入了两个缓存队列,这强一致性变成了最终一致性,还是达不到我们的预期,真不知道这个 MESI 有啥用啊。最终我们又引入了内存屏障来保证特定场景的强一致性。
完全遵守mesi会严重影响cpu的性能,而引入了store buffer和invalid queue,又打破了一致性。于是CPU把这个决定权让步给程序员,我只需要在希望一致性的地方加入内存屏障,这叫按需一致性。它分为读屏障和写屏障,屏蔽的就是store buffer和invalid queue这两个队列。我们在写操作后面加入写屏障,cpu就必须等待store buffer里的所有写操作都刷到其它cpu的invalid queue中;如果我们在读操作前面加入读屏障,cpu就必须先把invalid queue里的消息都消费完再去读变量值。这么一写一读组合起来就达到了一致的效果。其实这就是volatile的实现规范。如果一个java程序员每天crud还要去考虑底层一致性的问题,考虑在代码中的什么位置去加入内存屏障,这也太为难我们了吧,这是C++程序员才能干的活,所以JMM就是一套为java程序员定义的规范,它有一个happens before原则,说的就是java程序员在这些情况下不需要关心一致性的问题,它的底层屏障会自带内存屏障。happens before就是JMM对程序员的承诺,有了它我们在crud的时候就不用陷入底层的细节中。当然JMM还定义了一系列规范来保证底层的可见性、有序性和原子性。



