看懂这篇,才能说了解并发底层技术

爱生活,爱编码,本文已收录 架构技术专栏关注这个喜欢分享的地方。本文 架构技术专栏 已收录,有各类JVM、多线程、源码视频、资料以及技术文章等你来拿

零、开局

前两天我搞了两个每日一个知识点,对多线程并发的部分知识作了下归纳性的总结。但经过小伙伴的反馈是,那玩意写的比较抽象,看的云里雾里晕晕乎乎的。面试

因此又针对多线程底层这一块再从新作下系统性的讲解。
有兴趣的朋友能够先看下前两节,能够说是个笼统的概念版。缓存

好了,回归正题。在多线程并发的世界里synchronized、volatile、JMM是咱们绕不过去的技术坎,而重排序、可见性、内存屏障又有时候搞得你一脸懵逼。有道是知其然知其因此然,了解了底层的原理性问题,不管是平常写BUG仍是面试都是必备神器了。多线程

先看几个问题点:架构

一、处理器与内存之间是怎么交互的?并发

二、什么是缓存一致性协议?异步

三、高速缓存内的消息是怎么更新变化的?高并发

四、内存屏障又和他们有什么关系?oop

若是上面的问题你都能滚瓜烂熟,那就去看看电影放松下吧!编码

1、高速缓存

目前的处理器的处理能力要远远的胜于主内存(DRAM)访问的效率,每每主内存执行一次读写操做所需的时间足够处理器执行上百次指令。因此为了填补处理器与主内存之间的差距,设计者们在主内存和处理器直接引入了高速缓存(Cache)。如图:spa

其实在现代处理器中,会有多级高速缓存。通常咱们会成为一级缓存(L1 Cache)、二级缓存(L2 Cache)、三级缓存(L3 Cache)等,其中一级缓存通常会被集成在CPU内核中。如图:

内部结构

高速缓存存在于每一个处理器内,处理器在执行读、写操做的时候并不须要直接与内存交互,而是经过高速缓存进行。

高速缓存内其实就是为应用程序访问的变量保存了一个数据副本。高速缓存至关于一个容量极小的散列表(Hash Table),其键是一个内存地址,值是内存数据的副本或是咱们准备写入的数据。从其内部来看,其实至关于一个拉链散列表,也就是包含了不少桶,每一个桶上又能够包含不少缓存条目(想一想HashMap),如图:

缓存条目

在每一个缓存条目中,其实又包含了Tag、Data Block、Flag三个部分,我们来个小图:

  • Data Block : 也就是咱们经常叨叨的缓存行(Cache Line),她实际上是高速缓存与主内存间进行数据交互的最小单元,里面存储着咱们须要的变量数据。
  • Tag : 包含了缓存行中数据内存地址的信息(实际上是内存地址的高位部分的比特)
  • Flag : 标识了当前缓存行的状态(MESI咯)

那么,咱们的处理器又是怎么寻找到咱们须要的变量呢?

很少说,上图:

其实,在处理器执行内存访问变量的操做时,会对内存地址进行解码的(由高速缓存控制器执行)。而解码后就会获得tag、index 、offset三部分数据。

index : 咱们知道高速缓存内的结构是一个拉链散列表,因此index就是为了帮咱们来定位究竟是哪一个缓存条目的。

tag : 很明显和咱们缓存条目中的Tag 同样,因此tag 至关于缓存条目的编号。主要用于,在同一个桶下的拉链中来寻找咱们的目标。

offset : 咱们要知道一个前提,就是一个缓存条目中的缓存行是能够存储不少变量的,因此offset的做用是用来肯定一个变量在缓存行中的起始位置。

因此,在若是在高速缓存内能找到缓存条目而且定位到了响应得缓存行,而此时缓存条目的Flag标识为有效状态,这时候也就是咱们所说的缓存命中(Cache Hit),不然就是缓存未命中(Cache Miss)。

缓存未命有包括读未命中(Read Miss)和写未命中(Write Miss)两种,对应着对内存的读写操做。

而在读未命中(Read Miss) 产生时,处理器所须要的数据会从主内存加载并被存入高速缓存对应的缓存行中,此过程会致使处理器停顿(Stall)而不能执行其余指令。

2、缓存一致性协议

在多线程进行共享变量访问时,由于各个线程执行的处理器上的高速缓存中都会保存一份变量的副本数据,这样就会有一个问题,那当一个副本更新后怎么保证其它处理器能立刻的获取到最新的数据。这其实就是缓存一致性的问题,其本质也就是怎么防止数据的脏读。

为了解决这个问题,处理器间出现了一种通讯机制,也就是缓存一致性协议(Cache Coherence Protocol)。

MESI是什么

缓存一致性协议有不少种,MESI(Modified-Exclusive-Shared-Invalid)协议实际上是目前使用很普遍的缓存一致性协议,x86处理器所使用的缓存一致性协议就是基于MESI的。

咱们能够把MESI对内存数据访问理解成咱们经常使用的读写锁,它可使对同一内存地址的读操做是并发的,而写操做是独占的。因此在任什么时候刻写操做只能有一个处理器执行。而在MESI中,一个处理器要向内存写数据时必须持有该数据的全部权。

MESI将缓存条目的状态分为了Modified、Exclusive、Shared、Invalid四种,并在此基础上定义了一组消息用于处理器的读、写内存操做。如图:

MESI的四种状态

因此MESI其实就是使用四种状态来标识了缓存条目当前的状态,来保证了高速缓存内数据一致性的问题。那咱们来仔细的看下四种状态

Modified :

表示高速缓存中相应的缓存行内的数据已经被更新了。因为MESI协议中任意时刻只能有一个处理器对同一内存地址对应的数据进行更新,也就是说再多个处理器的高速缓存中相同Tag值得缓存条目只能有一个处于Modified状态。处于此状态的缓存条目中缓存行内的数据与主内存包含的数据不一致。

Exclusive:

表示高速缓存相应的缓存行内的数据副本与主内存中的数据同样。而且,该缓存行以独占的方式保留了相应主内存地址的数据副本,此时其余处理上高速缓存当前都不保留该数据的有效副本。

Shared:

表示当前高速缓存相应缓存行包含相应主内存地址对应的数据副本,且与主内存中的数据是一致的。若是缓存条目状态是Shared的,那么其余处理器上若是也存在相同Tag的缓存条目,那这些缓存条目状态确定也是Shared。

Invalid:

表示该缓存行中不包含任何主内存中的有效数据副本,这个状态也是缓存条目的初始状态。

MESI处理机制

前面说了那么多,都是MESI的基础理论,那么,MESI协议究竟是怎么来协调处理器进行内存的读写呢?

其实,想协调处理必然须要先和各个处理器进行通讯。因此MESI协议定义了一组消息机制用于协调各个处理器的读写操做。

咱们能够参考HTTP协议来进行理解,能够将MESI协议中的消息分为请求和响应两类。处理器在进行主内存读写的时候会往总线(Bus)中发请求消息,同时每一个处理器还会嗅探(Snoop)总线中由其余处理器发出的请求消息并在必定条件下往总线中回复响应得响应消息。

针对于消息的类型,有以下几种:

  • Read : 请求消息,用于通知其余处理器、主内存,当前处理器准备读取某个数据。该消息内包含待读取数据的主内存地址。
  • Read Response: 响应消息,该消息内包含了被请求读取的数据。该消息多是主内存返回的,也多是其余高速缓存嗅探到Read 消息返回的。
  • Invalidate: 请求消息,通知其余处理器删除指定内存地址的数据副本。其实就是告诉他们你这个缓存条目内的数据无效了,删除只是逻辑上的,其实就是更新下缓存条目的Flag.
  • Invalidate Acknowledge: 响应消息,接收到Invalidate消息的处理器必须回复此消息,表示已经删除了其高速缓存内对应的数据副本。
  • Read Invalidate: 请求消息,此消息为Read 和 Invalidate消息组成的复合消息,做用主要是用于通知其余处理器当前处理器准备更新一个数据了,并请求其余处理器删除其高速缓存内对应的数据副本。接收到该消息的处理器必须回复Read Response 和 Invalidate Acknowledge消息。
  • Writeback: 请求消息,消息包含了须要写入主内存的数据和其对应的内存地址。

了解完了基础的消息类型,那么咱们就来看看MESI协议是如何协助处理器实现内存读写的,看图说话:

举例:假如内存地址0xxx上的变量s 是CPU1 和CPU2共享的咱们先来讲下CPU上读取数据s

高速缓存内存在有效数据时:

CPU1会根据内存地址0xxx在高速缓存找到对应的缓存条目,并读取缓存条目的Tag和Flag值。若是此时缓存条目的Flag 是M、E、S三种状态的任何一种,那么就直接从缓存行中读取地址0xxx对应的数据,不会向总线中发送任何消息。

高速缓存内不存在有效数据时:

一、如CPU2 高速缓存内找到的缓存条目状态为I时,则说明此时CPU2的高速缓存中不包含数据s的有效数据副本。

二、CPU2向总线发送Read消息来读取地址0xxx对应的数据s.

三、CPU1(或主内存)嗅探到Read消息,则须要回复Read Response提供相应的数据。

四、CPU2接收到Read Response消息时,会将其中携带的数据s存入相应的缓存行并将对应的缓存条目状态更新为S。

从宏观的角度看,就是上面的流程了,咱们再继续深刻下,看看在缓存条目为I的时候究竟是怎么进行消息处理的

说完了读取数据,咱们就在说下CPU1是怎么写入一个地址为0xxx的数据s的

MESI协议解决了缓存一致性的问题,但其中有一个问题,那就是须要在等待其余处理器所有回复后才能进行下一步操做,这种等待明显是不能接受的,下面就继续来看看大神们是怎么解决处理器等待的问题的。

3、写缓冲和无效化队列

由于MESI自身有个问题,就是在写内存操做的时候必须等待其余全部处理器将自身高速缓存内的相应数据副本都删除后,并接收到这些处理器回复的Invalidate Acknowledge/Read Response消息后才能将数据写入高速缓存。

为了不这种等待形成的写操做延迟,硬件设计引入了写缓冲器和无效化队列。

写缓冲器(Store Buffer)

在每一个处理器内都有本身独立的写缓冲器,写缓冲器内部包含不少条目(Entry),写缓冲器比高速缓存还要小点。

那么,在引入了写缓冲器后,处理器在执行写入数据的时候会作什么处理呢?还会直接发送消息到BUS吗?

咱们来看几个场景:

(注意x86处理器是无论相应的缓存条目是什么状态,都会直接将每个写操做结果存入写缓冲器)

一、若是此时缓存条目状态是E或者M:

表明此时处理器已经获取到数据全部权,那么就会将数据直接写入相应的缓存行内,而不会向总线发送消息。

二、若是此时缓存条目状态是S

  • 此时处理器会将写操做的数据存入写缓冲器的条目中,并发送Invalidate消息。
  • 若是此时相应缓存条目的状态是I ,那就称之为写操做遇到了写未命中(Write Miss),此时就会将数据先写入写缓冲器的条目中,而后在发送Read Invalidate来通知其余处理器我要进行数据更新了。
  • 处理器的写操做其实在将数据写入缓冲器时就完成了,处理器并不须要等待其余处理器返回Invalidate Acknowledge/Read Response消息
  • 当处理器接收到其余处理器回复的针对于同一个缓存条目的Invalidate Acknowledge消息时,就会将写缓冲内对应的数据写入相应的缓存行中

经过上面的场景描述咱们能够看出,写缓冲器帮助处理器实现了异步写数据的能力,使得处理器处理指令的能力大大提高。

无效化队列(Invalidate Queue)

其实在处理器接到Invalidate类型的消息时,并不会删除消息中指定地址对应的数据副本(也就是说不会去立刻修改缓存条目的状态为I),而是将消息存入无效化队列以后就回复Invalidate Acknowledge消息了,主要缘由仍是为了减小处理器等待的时间。

因此无论是写缓冲器仍是无效化队列,其实都是为了减小处理器的等待时间,采用了空间换时间的方式来实现命令的异步处理。

总之就是,写缓冲器解决了写数据时要等待其余处理器响应得问题,无效化队列帮助解决了删除数据等待的问题。

但既然是异步的,那必然又会带来新的问题 -- 内存重排序和可见性问题。

因此,咱们继续接着聊。

存储转发(Store Fowarding)

经过上面内容咱们知道了有了写缓冲器后,处理器在写数据时直接写入缓冲器就直接返回了。

那么问题就来了,当咱们写完一个数据又要立刻进行读取可咋办呢?话很少说,我们仍是举个例子来讲,如图:

此时第一步处理器将变量S的更新后的数据写入到写缓冲器返回,接着立刻执行了第二布进行S变量的读取。因为此时处理器对S变量的更新结果还停留在写缓冲器中,所以从高速缓存缓存行中读到的数据仍是变量S的旧值。

为了解决这种问题,存储转发(Store Fowarding)这个概念上线了。其理论就是处理器在执行读操做时会先根据相应的内存地址从写缓冲器中查询。若是查到了直接返回,不然处理器才会从高速缓存中查找,这种从缓冲器中读取的技术就叫作存储转发。看图:

内存重排序和可见性的问题

因为写缓冲器和无效化队列的出现,处理器的执行都变成了异步操做。缓冲器是每一个处理器私有的,一个处理器所存储的内容是没法被其余处理器读取的。

举个例子:

CPU1 更新变量到缓冲器中,而CPU2由于没法读取到CPU1缓冲器内容因此从高速缓存中读取的仍然是该变量旧值。

其实这就是写缓冲器致使StoreLoad重排序问题,而写缓冲器还会致使StoreStore重排序问题等。

为了使一个处理器上运行的线程对共享变量所作的更新被其余处理器上运行的线程读到,咱们必须将写缓冲器的内容写到其余处理器的高速缓存上,从而使在缓存一致性协议做用下这次更新能够被其余处理器读取到。

处理器在写缓冲器满、I/O指令被执行时会将写缓冲器中的内容写入高速缓存中。但从变量更新角度来看,处理器自己没法保障这种更新的”及时“性。为了保证处理器对共享变量的更新可被其余处理器同步,编译器等底层系统借助一类称为内存屏障的特殊指令来实现。

内存屏障中的存储屏障(Store Barrier)会使执行该指令的处理器将写缓冲器内容写入高速缓存。

内存屏障中的加载屏障(Load Barrier)会根据无效化队列内容指定的内存地址,将相应处理器上的高速缓存中相应的缓存条目状态标记为I。

4、内存屏障

由于说了存储屏障(Store Barrier)和加载屏障(Load Barrier) ,因此这里再简单的提下内存屏障的概念。

划重点:(你细品)

处理器支持哪一种内存重排序(LoadLoad重排序、LoadStore重排序、StoreStore重排序、StoreLoad重排序),就会提供相对应可以禁止重排序的指令,而这些指令就被称之为内存屏障(LoadLoad屏障、LoadStore屏障、StoreStore屏障、StoreLoad屏障)

划重点:

若是用X和Y来代替Load或Store,这类指令的做用就是禁止该指令左侧的任何 X 操做与该指令右侧的任何 Y 操做之间进行重排序(就是交换位置),确保指令左侧的全部 X 操做都优先于指令右侧的Y操做。

内存屏障的具体做用:

屏障名称 示例 具体做用
StoreLoad Store1;Store2;Store3;StoreLoad;Load1;Load2;Load3 禁止StoreLoad重排序,确保屏障以前任何一个写(如Store2)的结果都会在屏障后任意一个读操做(如Load1)加载以前被写入
StoreStore Store1;Store2;Store3;StoreStore;Store4;Store5;Store6 禁止StoreStore重排序,确保屏障以前任何一个写(如Store1)的结果都会在屏障后任意一个写操做(如Store4)以前被写入
LoadLoad Load1;Load2;Load3;LoadLoad;Load4;Load5;Load6 禁止LoadLoad重排序,确保屏障以前任何一个读(如Load1)的数据都会在屏障后任意一个读操做(如Load4)以前被加载
LoadStore Load1;Load2;Load3;LoadStore;Store1;Store2;Store3 禁止LoadStore重排序,确保屏障以前任何一个读(如Load1)的数据都会在屏障后任意一个写操做(如Store1)的结果被写入高速缓存(或主内存)前被加载

5、总结

其实从头看到尾就会发现,一个技术点的出现每每是为了填补另外一个的坑。

为了解决处理器与主内存之间的速度鸿沟,引入了高速缓存,却又致使了缓存一致性问题

为了解决缓存一致性问题,引入了如MESI等技术,又致使了处理器等待问题

为了解决处理器等待问题,引入了写缓冲和无效化队列,又致使了重排序和可见性问题

为了解决重排序和可见性问题,引入了内存屏障,舒坦。。。

爱生活,爱编码,本文已收录 架构技术专栏关注这个喜欢分享的地方。本文 架构技术专栏 已收录,有各类JVM、多线程、源码视频、资料以及技术文章等你来拿

往期推荐

每日一个知识点系列:volatile的可见性原理

(最新 9000字) Spring Boot 配置特性解析

什么时候用多线程?多线程须要加锁吗?线程数多少最合理?

Spring Boot 知识清单(一)SpringApplication

高并发系统,你须要知道的指标(RT...)

相关文章
相关标签/搜索