天天看点

【刨根问底】带你深入理解JUC并发工具类 — 缓存一致性和内存屏障

大家好,我是Java不惑(WX公众号同名)。这是专栏的第二篇文章,我将给大家简单介绍一下volatile和cas的原理。

为什么说简单介绍,因为不同的处理器有不同的实现方式,并且处理器过于复杂,我们只需要简单了解就可以了。

在这篇文章中,我将向大家介绍缓存一致性协议,并介绍缓存一致性协议是怎样实现可见性和有序性。

lock指令前缀

对volatile修饰的变量,编译后的指令增加了lock指令的前缀:

lock add1 $0x0,(%esp)
           

CAS编译后,也会自动增加lock前缀。

lock cmpxchg
           

正是加了lock前缀,才让volatile修饰符具有可见性和有序性,也让cas可以原子的替换变量。

那么lock指令究竟让处理器做了什么操作呢?

在上一篇文章中,我们知道了内核之间是可以通信的,那么内核之间是怎么通信的呢?

带着疑问,我们继续向下看。

Lock指令在处理器中有两种处理实现方式:总线锁和缓存一致性协议。

总线锁

总线锁顾名思义,就是锁住总线。CPU总线负责CPU和外部(高速缓存、内存等)通信,使用总线锁会选择一个核心独占总线,其他内核不能和内存通信。

此时其它内核无法通信,该内核独享共享内存,也就解决了原子性问题。

但是内核无法通信时,开销比较大,在最初的处理器中提供这种方式,现在又提出了缓存一致性协议。

缓存一致性机制

现在处理器有专门的协议来解决多核缓存中数据一致性问题,比较经典的有MESI协议,下面我们主要介绍MESI协议。

上一篇文章中我们介绍过缓存行,在MESI的实现中,每个缓存行包含三部分:vaild、tag和block,三部分介绍如下:

  • vaild 用于标识该数据的有效性
  • tag 用于指示数据对应的内存地址
  • block 用于存储数据(64byte)

vaild是校验字段,我们能通过这个字段去判断缓存行是否是可用的。对于vaild字段有两种处理方式:

  • 一种方式是当一个内核修改了数据,其他内核如果有这份数据,就把valid标识为无效,这种方式叫做写失效;
  • 一种方式是当一个内核修改了数据,其他内核如果有这份数据,就更新为新值,标记valid有效,这种方式叫做写更新;

MESI协议使用的是写失效的思路。在MESI协议中,缓存行valid有四种状态:

1、 M(Modified,被修改):缓存行中的数据只缓存在该内核的缓存中,并被修改过。当前缓存行中的数据和主存中的数据不一致,需要在某个时间写回到主存中。

【刨根问底】带你深入理解JUC并发工具类 — 缓存一致性和内存屏障

2、 E(Exclusive,独享):该缓存行只被缓存在该内核的缓存中,并且是未被修改的,与主存中的数据一致。当其他内核读取该缓存行时变为共享状态。

【刨根问底】带你深入理解JUC并发工具类 — 缓存一致性和内存屏障

3、 S(Shared,共享):该缓存行可能被多个内核缓存,并且各缓存行中的数据和主数据一致,当有一个CPU修改数据时,其他内核中该缓存行中的数据被设置为无效状态。

【刨根问底】带你深入理解JUC并发工具类 — 缓存一致性和内存屏障

4、 I(Invalid,无效):该缓存行无效

内核通信

MESI协议要求缓存没有命中时,允许缓存在其他缓存中复制数据,所以减少了读取主存。

首先我们看一下内核修改数据后,会怎么处理,缓存行中的数据会怎么变化。

【刨根问底】带你深入理解JUC并发工具类 — 缓存一致性和内存屏障
  • 内核0修改data的数据为0,会先向所有内核广播,告诉其他内核要修改的数据
  • 内核1接收内核0的广播消息之后,会检查缓存中的包含该数据的缓存行
  • 内核1将查找到的缓存行删掉或者设置为无效状态(vaild设为I状态)
【刨根问底】带你深入理解JUC并发工具类 — 缓存一致性和内存屏障
  • 内核0收到内核1的反馈后,会将数据保存到缓存行中,并修改valid为E或者M
  • 内核0保存数据到内存中。

为了保证各个内核读取的data共享变量是最新的,core0 需要等到core1返回消息后才能继续执行。

但是内核0不可能等待接收到内核1的消息之后才进行后续操作。

所以新增了一个存储缓存的组件。

存储缓冲(Store Buffer)

【刨根问底】带你深入理解JUC并发工具类 — 缓存一致性和内存屏障

core0广播消息时,先将消息保存到StoreBuffer中,这样core0就不必知道其他内核是否已经接收到消息。

内核中引入Store Buffer解决了内核之间等待的问题,但是又引入了Store Buffer和Cache之间数据同步的问题。

为此,针对Store Buffer,CPU在后续变量新值写入之前,把Store Buffer的所有值按顺序刷新到内存中。

这就称为内存屏障中的写屏障(Store Barrier)

无效队列(Invalidate Queue)

core0广播数据修改之后,core1不可能马上处理,而是在内核中新增一个无效队列的组件,用于存放接收广播中的无效数据。

【刨根问底】带你深入理解JUC并发工具类 — 缓存一致性和内存屏障
  • 当其他内核收到无效指令时,不需要确认缓存行是否真正失效,而是先放到Invalidate Queue中,并返回无效指令确认;等待内核空闲时再处理Invalidate Queue中的无效指令。
  • Invalidate Queue的引入解决了内核不能及时回复消息的问题,但也带来了一些问题,比如未及时将缓存行设置为无效状态并使用了该缓存行。
  • 为此,针对Invalidate Queue,执行后需要等待Invalidate Queue完全应用到内存后,后续读操作才继续执行,保证执行前后的读操作对其他内核是顺序的,这也称为内存屏障中的读屏障(Load Barrier)。

volatile和缓存一致性协议

上面我们介绍了总线锁和缓存一致性,使用lock前缀的指令,内核会使用缓存一致性协议来处理共享数据。

内存屏障有读屏障、写屏障和全屏障(full barrier)等几种。

读屏障:获取其他内核修改,让当前内核中的数据为最新的值,也就是将Invalidate Queue中的数据应用到内核;

写屏障:将内核的修改让其他内核可见,将storeBuffer的数据写入缓存/内存中;

全屏障:是几种内存屏障里面开销最大的,包含了其他几种屏障;

因为有缓存一致性和总线锁,所以volatile实现了可见性。

同时lock具有full barrier的效果,所以volatile保证了有序性。

总结

在这篇文章中,我介绍了缓存一致性和volatile的关系,以及volatile是怎样实现可见性和有序性的。希望你看完有所收获,受限于个人水平,文章若有错漏,还望读者不吝赐教。

在下一篇文章中,我将向大家介绍Synchronized和Reentrantlock背后的设计思想,也就是大学教材《计算机操作系统》中的信号量和管程。

最后,如果我的文章对你有帮助,请帮我点赞转发!

如果你对volatile和cas不熟悉,可以看我的第一篇文章《【刨根问底】带你深入理解JUC并发工具类 — volatile和cas》

或者直接访问该专栏的导航文章:《【刨根问底】带你深入理解JUC并发工具类 — 开篇》