volatile 对可见性的保证并不是那么简单

 

  数据一致性部分借用大神“耗叔”的博客:https://coolshell.cn/articles/20793.html

  总结:volatile 关键字通过内存屏障禁止了指令的重排序,并在单个核心中,强制数据的更新及时更新到缓存。在此基础上,依靠多核心处理器的缓存一致性协议等机制,保证了变量的可见性。

  在学习 volatile 关键字时总是绕不开两点,保证数据及时更新到内存和禁止指令重排序,基于上述两点 volatile 关键字保证了共享变量在多个线程间的可见性。

  虽然说起来知识点不多,但实际上 volatile 的实现是及其复杂的。在 java5 之前 volatile 关键字会经常造成一些无法预料的错误,导致其保守诟病。直到 5 版本对 volatile 进行改进之后才重获新生,官方版本经历的这些波折足以证明其底层实现逻辑的复杂。

  我们重温一下 volatile 关键字实现涉及到的知识点。从大的分类来说,其涉及两个知识点:多核心中数据的一致性,禁止指令的乱序执行。我们一个一个来看。

  多核心中数据的一致性

  现代处理器为了提高内存数据的访问速度,都会有自带的多级缓存,其位置在内存与处理器之间。

  老的CPU会有两级内存(L1和L2),新的CPU会有三级内存(L1,L2,L3 )。其中:

  • L1缓分成两种,一种是指令缓存,一种是数据缓存。L2缓存和L3缓存不分指令和数据。
  • L1和L2缓存在每一个CPU核中,L3则是所有CPU核心共享的内存。
  • L1、L2、L3的越离CPU近就越小,速度也越快,越离CPU远,速度也越慢。

  再往后面就是内存,内存的后面就是硬盘。我们来看一些他们的速度:

  • L1 的存取速度:4 个CPU时钟周期
  • L2 的存取速度: 11 个CPU时钟周期
  • L3 的存取速度:39 个CPU时钟周期
  • RAM内存的存取速度:107 个CPU时钟周期

  可以看到,离处理器越近的缓存存取速度越快,缓存的存在极大的加快了处理器访问内存的速度。但事情总是有两面性的,缓存的存在加快了堆内存的访问速度,同时也带来了一系列额外的复杂性。每个 CPU 缓存中有一份自己的内存副本,会带来各个 CPU 在访问同一块内存的数据时,每个 CPU 缓存中的副本可能不一致的问题。

  一般来说,目前的 CPU 会有两种方法解决缓存不一致的问题:

  • Directory 协议。这种方法的典型实现是要设计一个集中式控制器,它是主存储器控制器的一部分。其中有一个目录存储在主存储器中,其中包含有关各种本地缓存内容的全局状态信息。当单个CPU Cache 发出读写请求时,这个集中式控制器会检查并发出必要的命令,以在主存和CPU Cache之间或在CPU Cache自身之间进行数据同步和传输。
  • Snoopy 协议。这种协议更像是一种数据通知的总线型的技术。CPU Cache通过这个协议可以识别其它Cache上的数据状态。如果有数据共享的话,可以通过广播机制将共享数据的状态通知给其它CPU Cache。这个协议要求每个CPU Cache 都可以窥探数据事件的通知并做出相应的反应。如下图所示,有一个Snoopy Bus的总线。

 

  因为Directory协议是一个中心式的,会有性能瓶颈,而且会增加整体设计的复杂度。而Snoopy协议更像是微服务+消息通讯,所以,现在基本都是使用Snoopy的总线的设计。

  这里,我想多写一些细节,因为这种微观的东西,不自然就就会更分布式系统相关联,在分布式系统中我们一般用Paxos/Raft这样的分布式一致性的算法。而在CPU的微观世界里,则不必使用这样的算法,原因是因为CPU的多个核的硬件不必考虑网络会断会延迟的问题。所以,CPU的多核心缓存间的同步的核心就是要管理好数据的状态就好了。

  这里介绍几个状态协议,先从最简单的开始,MESI协议,这个协议跟那个著名的足球运动员梅西没什么关系,其主要表示缓存数据有四个状态:Modified(已修改), Exclusive(独占的),Shared(共享的),Invalid(无效的)。

  MESI 这种协议在数据更新后,会标记其它共享的CPU缓存的数据拷贝为Invalid状态,然后当其它CPU再次read的时候,就会出现 cache miss 的问题,此时再从内存中更新数据。从内存中更新数据意味着20倍速度的降低。我们能不能直接从我隔壁的CPU缓存中更新?是的,这就可以增加很多速度了,但是状态控制也就变麻烦了。还需要多来一个状态:Owner(宿主),用于标记,我是更新数据的源。于是,现了 MOESI 协议

  MOESI协议的状态机和演示示例我就不贴了,我们只需要理解MOESI协议允许 CPU Cache 间同步数据,于是也降低了对内存的操作,性能是非常大的提升,但是控制逻辑也非常复杂。

  顺便说一下,与 MOESI 协议类似的一个协议是 MESIF,其中的 F 是 Forward,同样是把更新过的数据转发给别的 CPU Cache 但是,MOESI 中的 Owner 状态 和MESIF 中的 Forward 状态有一个非常大的不一样—— Owner状态下的数据是dirty的,还没有写回内存,Forward状态下的数据是clean的,可以丢弃而不用另行通知

  从上面我们可以看出,缓存一致协议很好的保证了多处理器间缓存一致性的问题。这样看来,我们并没有使用 volatile 关键字的必要,硬件层本身实现了多处理器间的缓存一致性并且对其上层是透明的。但事实并非如此,处理器除缓存外,计算单元与缓存系统间还隔着本地寄存器和缓冲区,处理器本身完成了计算但并没有及时将新值刷新到缓存的话,缓存一致协议并不会起作用,数据的新值对其它处理器的可见性依然无法得到保证。

  将新值及时刷新到缓存,这是单个处理器自己需要处理的问题,其依赖了内存屏障机制,下面我们会接着说。

指令的乱序执行

  在程序运行时,为了提升指令的执行效率,编译器或者CPU会对代码结构进行重新排序,达到最佳效果。

  编译器重排序:

  CPU只读一次的x和y值。不需反复读取寄存器来交替x和y值。编译器的重排序是为了更加高效的使用处理器。

  而对于处理器执行指令时,为了使流水线的效率最大化,也会对指令进行进一步的重排序。所以代码顺序并不是真正的执行顺序,只要有空间提高性能,CPU和编译器可以进行各种优化。

  同时缓存和主存的读取会利用load, store和write-combining缓冲区来缓冲和重排。这些缓冲区是查找速度很快的关联队列,当一个后来发生的load需要读取上一个store的值,而该值还没有到达缓存,查找是必需的,下图描绘的是一个简化的现代多核CPU,从下图可以看出执行单元可以利用本地寄存器和缓冲区来管理和缓存子系统的交互。

   这种以提升执行效率为目的的重排序可能会带来意想不到的后果。为了在必要的时候避免重排序的发生,处理器为我们提供了内存屏障机制。

  内存屏障提供了两个功能。首先,它们通过确保从另一个CPU来看屏障的两边的所有指令都是正确的程序顺序,而保持程序顺序的外部可见性;其次它们可以实现内存数据可见性,确保内存数据会同步到CPU缓存子系统。

  大多数的内存屏障都是复杂的话题。在不同的CPU架构上内存屏障的实现非常不一样。相对来说Intel CPU的强内存模型比DEC Alpha的弱复杂内存模型(缓存不仅分层了,还分区了)更简单。下面以x86架构为例。

Store Barrier

  Store屏障,是x86的”sfence“指令,强制所有在store屏障指令之前的store指令,都在该store屏障指令执行之前被执行,并把store缓冲区的数据都刷到CPU缓存。这会使得程序状态对其它CPU可见,这样其它CPU可以根据需要介入。

Load Barrier

  Load屏障,是x86上的”ifence“指令,强制所有在load屏障指令之后的load指令,都在该load屏障指令执行之后被执行,并且一直等到load缓冲区被该CPU读完才能执行之后的load指令。这使得从其它CPU暴露出来的程序状态对该CPU可见,这之后CPU可以进行后续处理。

Full Barrier

  Full屏障,是x86上的”mfence“指令,复合了load和save屏障的功能。

  volatile变量在写操作之后会插入一个store屏障,在读操作之前会插入一个load屏障。一个类的final字段会在初始化后插入一个store屏障,来确保final字段在构造函数初始化完成并可被使用时可见。

   通过内存屏障,保证了对volatile 的写操作一定会及时刷新到 CPU 缓存系统,通过 CPU 的缓存一致性协议,进而保证了多核环境下 volatile 的 happen-before 原则,一个对 volatile 的写操作只要发生在读操作之前,写的结果一定对读操作可见。

  但可见性并不等于原子性,其只是原子性的必要条件。比如两个线程同时对一个变量进行写操作,依然会造成变量污染,而且这并不违反 happen-before 原则。

猜你喜欢

转载自www.cnblogs.com/niuyourou/p/12397004.html
今日推荐