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的并行执行。

  运行期重排是指运行过程中,CPU动态分析各部件效能,对指令进行重排优化。

  编译器重排序:

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

  编译器重排序时会考虑指令的依赖性:

  1. 数据依赖性
  如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型,这3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。

 

   2. 控制依赖性
  flag变量是个标记,用来标识变量a是否已被写入,在use方法中比变量i依赖if (flag)的判断,这里就叫控制依赖,如果发生了重排序,结果就不对了。

  由此提出了 as-if-serial 语义,不管如何重排序,都必须保证代码在单线程下的运行正确,连单线程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个as-if-serial的概念。

  as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作依然可能被编译器和处理器重排序。
  as-if-serial 并没有禁止存在控制依赖的指令进行重排序,因为控制依赖会降低流水线的并行度,所以处理器层面在处理条件分支时会采用猜测执行/流水线冒险来对分支指令进行预测执行,并在执行完成后对分支条件进行检查和校验冒险结果。所以无论是否重排序,处理器都会对其正确性进行检验。

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

  比如在执行对两个内存块A和B的赋值时(A先于B),由于 A 所处缓存块的地址处于 busy 状态(比如超线程情况下两个线程竞争同一个缓存地址),CPU 为了防止 cache wait ,会尝试先对 B 赋值,这就改变了原指令的执行顺序。

  同时缓存和主存的读取会利用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可以进行后续处理。

       毕竟多核心之间有缓存一致性协议,但并没有缓冲区一致性协议,这些CPU本身携带的缓冲到缓存读写的小部件中很可能存在脏数据。而处理器每次读缓存之前都会先尝试读取这些缓冲区中的数据,所以我们需要读完其中的值防止数据的污染。

      多提一句缓冲区。缓存到内存我们会有合并写,缓冲区到缓存也会有合并写,合理的写代码去充分的利用它们将会大大提升程序的效率。比如如果一个核心有四个写缓冲区,而我们的一个循环中只改变四个变量的值,那么一直到循环结束,cpu可能只与缓存打了两次交道,一次读入初始数据,一次合并写。其它的运算全部是在缓冲区中进行的,少了到缓存的读写,效率可想而知。

Full Barrier

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

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

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

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

        总的来说,CPU与编译器的指令重排序总会保证程序的串行语义,也就是在单线程环境下的正确性。维持这种串行语义的依据便是指令间的数据依赖关系。但是在多线程的情况下,不同线程之间的指令是不存在数据依赖关系的(或者说机器无法分析出来不同线程中的数据依赖关系),但是我们的线程在进行协作时不可避免的会产生这种依赖关系。比如线程a对共享变量赋值与线程a在为共享变量赋值后唤醒线程b的两个指令从单线程的角度看是不存在数据依赖关系的,可以对其进行重排序。但从多线程角度来说,线程b的运行是基于线程a改变共享变量的值这个动作的结果的。

        所以我们必须保证线程a改变共享变量的结果对下一条指令,也就是唤醒线程b可见。那么我们需要禁止这两条指令的重排序,并且保证共享变量在运行a.b线程的处理器中的一致性。这时我们便可以采用内存屏障技术,禁止其重排序,并保证数据的改变可以及时flush。

        还有一点需要注意的是,说了这么多多内存屏障,内存屏障是处理器为我们提供的机制,其保证的是处理器层面指令在屏障前后的执行顺序。至于编译期重排序保证串行化语义以及特定多线程情况下不打破指令的数据依赖,是由编译器来保证的。java在 编译时通过遵循happen before原则来保证了这一点。

posted @ 2020-03-02 19:00  牛有肉  阅读(3500)  评论(2编辑  收藏  举报