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

2024-02-01 03:48

本文主要是介绍并发、原子、可见有序性在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的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

这里有一篇不错的内存屏障文章:
https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/

对于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()方法.

这篇关于并发、原子、可见有序性在MESI协议、内存屏障的硬件原理的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/665999

相关文章

Python中使用uv创建环境及原理举例详解

《Python中使用uv创建环境及原理举例详解》uv是Astral团队开发的高性能Python工具,整合包管理、虚拟环境、Python版本控制等功能,:本文主要介绍Python中使用uv创建环境及... 目录一、uv工具简介核心特点:二、安装uv1. 通过pip安装2. 通过脚本安装验证安装:配置镜像源(可

Redis过期删除机制与内存淘汰策略的解析指南

《Redis过期删除机制与内存淘汰策略的解析指南》在使用Redis构建缓存系统时,很多开发者只设置了EXPIRE但却忽略了背后Redis的过期删除机制与内存淘汰策略,下面小编就来和大家详细介绍一下... 目录1、简述2、Redis http://www.chinasem.cn的过期删除策略(Key Expir

Mysql的主从同步/复制的原理分析

《Mysql的主从同步/复制的原理分析》:本文主要介绍Mysql的主从同步/复制的原理分析,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录为什么要主从同步?mysql主从同步架构有哪些?Mysql主从复制的原理/整体流程级联复制架构为什么好?Mysql主从复制注意

Nacos注册中心和配置中心的底层原理全面解读

《Nacos注册中心和配置中心的底层原理全面解读》:本文主要介绍Nacos注册中心和配置中心的底层原理的全面解读,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录临时实例和永久实例为什么 Nacos 要将服务实例分为临时实例和永久实例?1.x 版本和2.x版本的区别

apache的commons-pool2原理与使用实践记录

《apache的commons-pool2原理与使用实践记录》ApacheCommonsPool2是一个高效的对象池化框架,通过复用昂贵资源(如数据库连接、线程、网络连接)优化系统性能,这篇文章主... 目录一、核心原理与组件二、使用步骤详解(以数据库连接池为例)三、高级配置与优化四、典型应用场景五、注意事

python多线程并发测试过程

《python多线程并发测试过程》:本文主要介绍python多线程并发测试过程,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录一、并发与并行?二、同步与异步的概念?三、线程与进程的区别?需求1:多线程执行不同任务需求2:多线程执行相同任务总结一、并发与并行?1、

电脑系统Hosts文件原理和应用分享

《电脑系统Hosts文件原理和应用分享》Hosts是一个没有扩展名的系统文件,当用户在浏览器中输入一个需要登录的网址时,系统会首先自动从Hosts文件中寻找对应的IP地址,一旦找到,系统会立即打开对应... Hosts是一个没有扩展名的系统文件,可以用记事本等工具打开,其作用就是将一些常用的网址域名与其对应

Dubbo之SPI机制的实现原理和优势分析

《Dubbo之SPI机制的实现原理和优势分析》:本文主要介绍Dubbo之SPI机制的实现原理和优势,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录Dubbo中SPI机制的实现原理和优势JDK 中的 SPI 机制解析Dubbo 中的 SPI 机制解析总结Dubbo中

Java内存区域与内存溢出异常的详细探讨

《Java内存区域与内存溢出异常的详细探讨》:本文主要介绍Java内存区域与内存溢出异常的相关资料,分析异常原因并提供解决策略,如参数调整、代码优化等,帮助开发者排查内存问题,需要的朋友可以参考下... 目录一、引言二、Java 运行时数据区域(一)程序计数器(二)Java 虚拟机栈(三)本地方法栈(四)J

java变量内存中存储的使用方式

《java变量内存中存储的使用方式》:本文主要介绍java变量内存中存储的使用方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录1、介绍2、变量的定义3、 变量的类型4、 变量的作用域5、 内存中的存储方式总结1、介绍在 Java 中,变量是用于存储程序中数据