并发编程之缓存一致性
Java内存模型(JMM)的设计是建立在物理机的内存模型之上的,因此了解物理机内存模型的原理也十分重要。简单来说,物理机的内存模型经历了3个阶段:
- 早期的CPU计算速率与内存操作速率相当,CPU直接从内存中读取数据,运行程序计算,得出结果再写入内存;
- 后来CPU飞速发展,内存的速率已经远不及CPU的计算了,这时CPU计算任务因等待内存数据读取而停滞,造成计算资源浪费,于是人们设计了缓存,CPU通过读写缓存来获取操作数,结果数据也通过缓存写入内存。缓存的读写速度尽量接近CPU,在一定程度上缓解了CPU计算资源浪费的情况。CPU越来越快时,缓存的速度也相应提高,人们就通过设计多级缓存作为CPU与内存之间的缓冲,如现在的二级缓存,三级缓存。
- CPU发展到达瓶颈,单个核的计算频率已经很难提高了。人们为了获得更高的处理效率开始引入多核CPU。在多核CPU中,每个核拥有自己的缓存。可能会出现两个核的缓存里都拥有相同数据的副本,在两个核独自进行修改的情况下导致数据的不一致。因此需要解决缓存的一致性问题。
1、问题的根源
缓存中存储数据,缓存不一致就意味着相同的数据在不同的缓存中呈现着不同的表现。对于存储的数据,CPU有读操作和写操作,读操作不会影响数据的存储状态,写操作是导致不一致的根源。但是缓存不一致的问题并不是因为我们采用了多核CPU,而是因为我们采用了多个缓存。如果多核CPU共享一个缓存,那么不一致问题也不复存在,在每个时钟周期,几个核通过某种方式竞争使用缓存,每个时刻只允许一个核对缓存进行读写操作,其他的核都需排队等候,这样缓存永远是一致的,但是这种方式会导致CPU计算资源的极大浪费,同时效率极低。采用每个核一个缓存的方式,多核可以同时工作,但是也带来了缓存不一致的问题。
因此问题的根源不在于多个核,而是多个缓存,以及缓存的写操作。
2、缓存一致性协议
为了解决缓存不一致的问题,我们需要一种机制来约束各个核,也就是缓存一致性协议。
我们常用的缓存一致性协议都是属于“snooping(窥探)”协议,各个核能够时刻监控自己和其他核的状态,从而统一管理协调。窥探的思想是:CPU的各个缓存是独立的,但是内存却是共享的,所有缓存的数据最终都通过总线写入同一个内存,因此CPU各个核都能“看见”总线,即各个缓存不仅在进行内存数据交换的时候访问总线,还可以时刻“窥探”总线,监控其他缓存在干什么。因此当一个缓存在往内存中写数据时,其他缓存也都能“窥探”到,从而按照一致性协议保证缓存间的同步。
3、MESI协议
MESI协议是一种常用的缓存一致性协议,它通过定义一个状态机来保证缓存的一致性。在MESI协议中有四种状态,这些状态都是针对缓存行(缓存由多个缓存行组成,缓存行的大小单位与机器的位数相关)。
- (I)Invalid状态:缓存行无效状态。要么该缓存行数据已经过时,要么缓存行数据已经不在缓存中。对于无效状态,可直接认为缓存行未加载进缓存。
- (S)Shared状态:缓存行共享状态。缓存行数据与内存中对应数据保持一致,多个缓存中的相应缓存行都是共享状态。该状态下的缓存行只允许读取,不允许写。
- (E)Exclusive状态:缓存行独有状态。该缓存行中的数据与内存中对应数据保持一致,当某缓存行是独有状态,其他缓存对应的缓存行都必须为无效状态。
- (M)Modified状态:缓存行已修改状态。缓存行中的数据为脏数据,与内存中的对应数据不一致。如果一个缓存行为已修改状态,那么其他缓存中对应缓存行都必须为无效状态。另外,如果该状态下的缓存行状态被修改为无效,那么脏段必须先回写入内存中。
MESI协议的定律:所有M状态下的缓存行(脏数据)回写后,任意缓存级别中的缓存行的数据都与内存保持一致。另外,如果某个缓存行处于E状态,那么在其他的缓存中就不会存在该缓存行。
MESI协议保证了缓存的强一致性,在原理上提供了完整的顺序一致性。可以说在MESI协议实现的内存模型下,缓存是绝对一致的,但是这也会导致一些效率的问题,我们平时使用的机器往往都不会采用这种强内存模型,而是在这个基础上去使用较为弱一些的内存模型:如允许CPU读写指令的重排序等。这些弱内存模型可以带来一定的效率提升,但是也引入了一些语义上的问题。
4、内存模型
前面讲说MESI协议保证了缓存的强一致性,但是其实在这个基础上还需要对CPU提出两点要求:
- CPU缓存要及时响应总线事件
- CPU严格按照程序顺序执行内存操作指令
只要保证了以上两点,缓存一致性就能得到绝对的保证。但是由于效率的原因,CPU不可能保证以上两点:
- 首先,总线事件到来之际,缓存可能正在执行其他的指令,例如向CPU传输数据,那么缓存就无法马上响应总线事件了
- 其次,CPU如果严格按照程序顺序执行内存操作指令,意味着修改数据之前,必须要等到所有其他缓存的失效确认(Invalidate Acknowledge),这个等待的过程严重影响CPU的计算效率,因此现代CPU大都采用存储缓冲(Store Buffer)来暂时缓存写入的数据,等所有的失效确认完成之后,再向内存中回写数据。正是因为使用了存储缓冲,导致一些数据的内存写入操作可能会晚于程序中的顺序,也就是重排序(reorder)。
- 另外,CPU的存储缓冲大小是有限制的,有一些数据的回写还是需要等待其他缓存的失效确认,而且失效操作本身也是比较耗时的,于是引入了失效队列(invalidation queue)的概念
- 对于到来的失效请求,失效确认消息必须马上发出;
- 发出消息后,失效操作放入失效队列,并不马上执行;
- 对于正在处理的缓存,CPU不给它发送任何消息
由于引入了存储缓冲和失效队列的概念,CPU的指令执行顺序就更加混乱,读操作有可能会读取到过时的数据(失效操作还在失效队列中),写操作完成的时间可能比程序中的时间要晚(写操作的数据在存储缓冲中)。对应于内存模型就分成了两个阵营:弱内存模型和强内存模型。弱内存模型的体系架构中,上述重排序优化的情况不能保证完全一致性,需要用户代码去保证,这样用户代码会比较复杂一些,CPU的执行效率也就更高。而在强内存模型的体系架构中则相反,CPU负责实现复杂的操作来保证一致性,用户代码简单但是执行效率低。
那么在弱内存模型下的用户代码如何保证CPU上述重排序动作不会导致一致性的问题呢:内存屏障(memory barriers):
- 写屏障(store barrier):在执行屏障之后的指令之前,先执行所有已经在存储缓冲中保存的指令。
- 读屏障(load barrier):在执行任何的加载指令之前,先应执行所有已经在失效队列中的指令。
有了内存屏障,就可以保证缓存的一致性了。这里所说的都是物理架构中的缓存情况,对于并发编程中的JMM,编译器在生成字节码的时候会插入特定类型的内存屏障来禁止重排序, 保证多线程下的内存可见性,具体在JMM中是如何实现的,下次再分析。