并发、原子、可见有序性在MESI协议、内存屏障的硬件原理

众所周知的几个知识点

  • volatile保证了可见性和有序性,仅在32位long、double类型保证原子性;
  • synchronized保障了原子、有序、可见性,实际上是内部锁;
  • 显式的可重入锁ReentrantLock或者一些工具类如Semaphore, CountDownLatch保障原子、有序、可见性,基于AQS,即AbstractQueuedSynchronizer实现;

那么它们的硬件级别原理是什么?

CPU一致性协议MESI

比较经典的Cache一致性协议当属MESI协议

CPU中每个缓存行(caceh line)使用4种状态进行标记(使用额外的两位(bit)表示):

  • M: 被修改(Modified)

该缓存行只被缓存在该CPU的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取主存中相应内存之前)写回(write back)主存。当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。

  • E: 独享的(Exclusive)

该缓存行只被缓存在该CPU的缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态(shared)。

同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。

  • S: 共享的(Shared)

该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。

  • I: 无效的(Invalid)

该缓存是无效的(可能有其它CPU修改了该缓存行)。

介绍MESI之前先复习一下内存缓存交换机制和多线程模型。

高速缓存和主存交换机制

  • 程序以及数据被加载到主内存
  • 指令和数据被加载到CPU的高速缓存
  • CPU执行指令,把结果写到高速缓存
  • 高速缓存中的数据写回主内存
  • 其中还有写缓冲器和无效队列用于在一致性操作中加速优化执行效率

 JAVA多线程内存模型

在JVM规范中,将内存空间分为:方法区(METHOD AREA)、堆(HEAP)、本地方法栈(NATIV METHOD STACK)、PC寄存器(PROGRAM COUNTER REGISTER)、JAVA栈(JAVA STACK)。理论上说所有的栈和堆都存储在主内存中,但随着CPU运算其数据的副本可能被缓存或者寄存器持有。更高的效率java虚拟机、硬件系统可能让工作内存(存储线程使用的共享数据)优先分配在寄存器、缓存中。线程对共享内存的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写。不同线程无法直接访问其他线程工作内存中的变量,因此共享变量的值传递需要通过主内存完成。

现在CPU大多数情况下读写都不会直接访问内存(CPU都没有连接到内存的管脚),取而代之的是CPU缓存,CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小得多但是交换速度却比内存快得多。而缓存中的数据是内存中的一小部分数据,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度。

按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:

一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存。
二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半。
三级缓存:简称L3 Cache,部分高端CPU才有。
每一级缓存中所存储的数据全部都是下一级缓存中的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也相对递增。

当CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存中或内存中查找。一般来说每级缓存的命中率大概都有80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取。

内存和缓存之间的8种同步操作

变量从主内存读取到工作内存,然后同步回工作内存的细节,这就是主内存与工作内存之间的交互协议。Java内存模型定义了以下8种操作来完成,它们都是原子操作(除了对long和double类型的变量,因为存在高低32位的不同步一致性)

  • 锁定(lock):作用于主内存中的变量,将他标记为一个线程独享变量。通常意义上的上锁,就是一个线程正在使用时,其他线程必须等待该线程任务完成才能继续执行自己的任务。
  • 解锁(unlock):作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。执行完成后解开锁。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。从主内存 读取到工作内存中。
  • load(载入):把read操作从主内存中得到的变量值放入工作内存的变量的副本中。给工作内存中的副本赋值。
  • use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。程序执行过程中读取该值时调用。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。将运算完成后的新值赋回给工作内存中的变量,相当于修改工作内存中的变量。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。将该值从变量中取出,写入工作内存中。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。将工作内存中的值写回主内存。

复习完了内存缓存交换机制和多线程模型。,继续回到MESI协议。

MESI协议状态可以转换,即每个cache line所处的状态根据本核和其它核的读写操作在4个状态间进行转换。具体的状态转换可由下图表示:

Local Read表示本内核读本Cache中的值,Local Write表示本内核写本Cache中的值,Remote Read表示其它内核读其它Cache中的值,Remote Write表示其它内核写其它Cache中的值。

协议协作如下:

  • 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。
  • 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
  • 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。
  • 当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。
  • 当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下会性能开销是相对较大的。在写入完成后,修改其缓存状态为M。
另外MESI协议为了提高性能,引入了Store Buffer(即写缓存器)和Invalidate Queues,还是有可能会引起缓存不一致,还会再引入内存屏障来确保一致性。

Store Buffer也就是常说的写缓存器,当处理器修改缓存时,把新值放到存储缓存中,处理器就可以去干别的事了,把剩下的事交给存储缓存。Invalidate Queues(无效队列)处理失效的缓存也不是简单的,需要读取主存。并且存储缓存也不是无限大的,那么当存储缓存满的时候,处理器还是要等待失效响应的。为了解决上面两个问题,引进了失效队列(invalidate queue)。处理失效的工作如下:

  • 收到失效消息时,放到失效队列中去。
  • 为了不让处理器久等失效响应,收到失效消息需要马上回复失效响应。
  • 为了不频繁阻塞处理器,不会马上读主存以及设置缓存为invlid,合适的时候再一块处理失效队列

内存屏障(用于可见和有序性)

编译器在编译代码时会对源代码进行优化,其中之一就是代码重排。由于单核处理器能确保与「顺序执行」相同的一致性,所以在单核处理器上并不需要专门做什么处理就可以保证正确的执行顺序。但在多核处理器上通常需要使用内存屏障指令来确保这种一致性。
几乎所有的处理器至少支持一种粗粒度的屏障指令,通常被称为「栅栏(Fence)」,它保证在栅栏前初始化的load和store指令,能够严格有序的在栅栏后的load和store指令之前执行。无论在何种处理器上,这几乎都是最耗时的操作之一(与原子指令差不多,甚至更消耗资源),所以大部分处理器支持更细粒度的屏障指令。
下面是一些屏障指令的通常分类:

LoadLoad
序列:Load1,Loadload,Load2
确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入。通常能执行预加载指令或/和支持乱序处理的处理器中需要显式声明Loadload屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。 而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。

StoreStore
序列:Store1,StoreStore,Store2
确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么它需要使用StoreStore屏障。

LoadStore
序列:Load1,LoadStore,Store2
确保Load1的数据在Store2和后续Store指令被刷新之前读取。在Store指令可以越过load指令的乱序处理器上需要使用LoadStore屏障。

StoreLoad
序列:Store1,StoreLoad,Load2
确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见。StoreLoad屏障可以防止一个后续的load指令 不正确的使用了Store1的数据,而不是另一个处理器在相同内存位置写入一个新数据。在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

这里有一篇不错的内存屏障文章:

对于volatile关键字

按照规范会有下面的操作:

  • 在每个volatile写入之前,插入一个StoreStore,写入之后,插入一个StoreLoad
  • 在每个volatile读取之前,插入LoadLoad,之后插入LoadStore
  • 由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。
所以保障了可见性和有序性。

对于final域

也用到了内存屏障

  • 写final域:在编译器写final域完毕,构造体结束之前,会插入一个StoreStore屏障,保证前面的对final写入对其他线程/CPU可见,并阻止重排序。
  • 读final域:在上述规则2中,两步操作不能重排序的机理就是在读final域前插入了LoadLoad屏障。

对于synchronized

  • 原子性是通过ObjectMonitor的计数器实现可重入锁机制,有一些类似显示可重入锁的AQS的State机制;
  • 可见性是使用Load+Store屏障,内部是释放锁的时候Flush,加锁的是有Refresh;
  • 有序性是使用Acquire+Release屏障;

Acquire与Release语义

  • 对于Acquire来说,保证Acquire后的读写操作不会发生在Acquire动作之前
  • 对于Release来说,保证Release前的读写操作不会发生在Release动作之后

Acquire & Release 语义保证内存操作仅在acquire和release屏障之间发生。

X86-64中Load读操作本身满足Acquire语义,Store写操作本身也是满足Release语义。但Store-Load操作间等于没有保护,因此仍需要靠mfence或lock等指令才可以满足到Synchronizes-with规则。

Happen-Before先行发生规则

如果光靠sychronized和volatile来保证程序执行过程中的原子性, 有序性, 可见性, 那么代码将会变得异常繁琐.

JMM提供了Happen-Before规则来约束数据之间是否存在竞争, 线程环境是否安全, 具体如下:

  • 顺序原则

一个线程内保证语义的串行性; a = 1; b = a + 1;

  • volatile规则

volatile变量的写,先发生于读,这保证了volatile变量的可见性,

  • 锁规则

解锁(unlock)必然发生在随后的加锁(lock)前.

  • 传递性

A先于B,B先于C,那么A必然先于C.

  • 线程启动, 中断, 终止

线程的start()方法先于它的每一个动作.线程的中断(interrupt())先于被中断线程的代码.线程的所有操作先于线程的终结(Thread.join()).

  • 对象终结

对象的构造函数执行结束先于finalize()方法.

posted @ 2020-04-26 17:40  昕友软件开发  阅读(779)  评论(0编辑  收藏  举报
欢迎访问我的开源项目:xyIM企业即时通讯