零。开始前两天,每天做了两个知识点,对多线程并发的部分知识做了一个大概的总结。但是朋友们的反馈是那个东西写的比较抽象,看着就头晕。所以我再针对多线程的底层做一个系统的讲解。好了,回到主题。在多线程并发的世界里,synchronized、volatile、JMM是我们绕不过去的技术坎,而reordering、visibility、memorybarriers有时会让你一头雾水。有道知其然,知其所以然,理解其背后的原理性问题。无论是日常写BUG还是面试,都是必备神器。我们先来看几个问题:1.处理器和内存是如何交互的?2.什么是缓存一致性协议?3、缓存中的消息是如何更新的?4、内存屏障和它们有什么关系?如果你能流利地记住上面的问题,那就去看电影放松一下吧!1、缓存当前处理器的处理能力远优于主存(DRAM)的存取效率。通常,主存储器执行一次读写操作所需的时间足以让处理器执行数百条指令。因此,为了填补处理器和主存之间的空隙,设计者直接在主存和处理器之间引入了缓存(Cache)。如图:其实在现代的处理器中,都会有多级缓存。一般我们会变成一级缓存(L1Cache)、二级缓存(L2Cache)、三级缓存(L3Cache)等,而一级缓存一般集成在CPU中核。如图:内部结构缓存存在于每个处理器中。处理器在进行读写操作时,不需要直接与内存进行交互,而是通过缓存进行交互。缓存实际上保存了应用程序访问的变量的数据副本。缓存相当于一个容量很小的哈希表(HashTable),它的key是一个内存地址,value是内存数据或者我们要写入的数据的一份副本。从它的内部来看,它其实相当于一个拉链哈希表,即包含了很多桶,每个桶可以包含很多缓存项(想想HashMap),如图:缓存项在每个缓存中entry,其实也包括三部分:Tag,DataBlock,Flag。来个小图:**DataBlock:**就是我们常说的缓存行(CacheLine)。它实际上是缓存和主存之间的数据交换。交互的最小单位,里面存放着我们需要的变量数据。**Tag:**包含数据在cacheline中的内存地址信息(实际上是内存地址高位部分的bits)Flag:标识当前cacheline的状态(MESI略)那么,如何我们的处理器找到我们需要的变量了吗?话不多说,上图:其实处理器在进行内存访问变量操作时,会解码内存地址(由缓存控制器执行)。解码后会得到三部分数据:tag、index、offset。index:我们知道cache中的结构是一个zipperhashtable,那么index就是帮助我们定位到它是哪个cacheentry。tag:显然和我们缓存条目中的Tag是一样的,所以tag就相当于缓存条目的编号。主要用于,在同一桶下的拉链中找到我们的目标。offset:我们要知道一个前提,一个cacheentry中的cacheline可以存储很多变量,所以offset的作用就是确定一个变量在cacheline中的起始位置。因此,如果能够在缓存中找到缓存项并找到对应的缓存行,且此时缓存项的Flag有效,这就是我们所说的缓存命中(CacheHit),否则就是一个缓存未命中(CacheMiss)。缓存未命中有两种,分别是读未命中(ReadMiss)和写未命中(WriteMiss),分别对应对内存的读写操作。当发生读未命中(ReadMiss)时,处理器所需的数据会从主存中加载,并存储到缓存的相应缓存行中。这个过程会导致处理器停顿(Stall),无法执行其他指令。2.缓存一致性协议当多线程访问共享变量时,由于每个线程都会在每个线程执行的处理器上的缓存中保存一份变量数据的副本,所以会出现一个问题,一份副本如何更新?确保其他处理器可以立即获取最新数据。这其实是缓存一致性的问题,其实质是如何防止数据脏读。为了解决这个问题,处理器之间有一种通信机制,就是缓存一致性协议(CacheCoherenceProtocol)。什么是MESI?缓存一致性协议有很多。MESI(Modified-Exclusive-Shared-Invalid)协议实际上是一种广泛使用的缓存一致性协议。x86处理器使用的缓存一致性协议基于MESI。MESI对内存数据的访问我们可以理解为我们常用的读写锁,可以让对同一个内存地址的读操作是并发的,而写操作是独占的。所以在任何时候只有一个处理器可以执行写操作。在MESI中,当处理器想要将数据写入内存时,它必须拥有数据的所有权。MESI将缓存条目的状态分为Modified、Exclusive、Shared和Invalid四种类型,并在此基础上定义了处理器读写内存操作的一组消息。如图:MESI的四种状态所以MESI实际上是使用四种状态来标识缓存条目的当前状态,以保证缓存中数据的一致性。那么我们再仔细看看四种状态Modified:表示缓存中对应的缓存行中的数据已经被更新了。由于在MESI协议中任何时刻只有一个处理器可以更新同一内存地址对应的数据,也就是说,多个处理器的缓存中只有一个Tag值相同的缓存条目可以处于Modified状态。处于这种状态的高速缓存条目中的高速缓存行内的数据与主存储器中包含的数据不一致。Exclusive:表示缓存对应缓存行中的数据副本与主存中的数据相同。而且,该缓存行独占保留了对应主存地址的数据副本,此时,当前没有其他处理缓存保留该数据的有效副本。Shared:表示当前cache对应的cacheline包含了对应主存地址对应的数据的副本,并且与主存中的数据一致。如果缓存条目的状态是Shared,那么如果其他处理器上有相同Tag的缓存条目,那么这些缓存条目的状态也一定是Shared。Invalid:表示该缓存行在主存中不包含任何有效的数据副本,该状态也是该缓存条目的初始状态。MESI处理机制前面说了这么多,就是MESI的基础理论。那么,MESI协议是如何协调处理器读写内存的呢?事实上,如果要协调处理,首先要与各个处理器进行通信。因此,MESI协议定义了一套消息机制来协调各个处理器的读写操作。我们可以参考HTTP协议来理解,MESI协议中的消息可以分为请求和响应两种。处理器在读写主存时,会向总线(Bus)发送请求报文。同时,每个处理器也会嗅探(Snoop)总线上其他处理器发送的请求报文,并在一定条件下发送到总线上。在响应中回复响应消息。对于消息的类型,有以下几种:Read:请求消息,用于通知其他处理器和主存当前处理器已经准备好读取某个数据。该消息包含要读取的数据的主存地址。读取响应:包含要读取的请求数据的响应消息。此消息可能从主内存返回,或从嗅探Read消息的其他缓存返回。Invalidate:请求消息,通知其他处理器删除指定内存地址的数据副本。其实就是告诉他们,你的缓存条目中的数据是无效的,删除只是合乎逻辑的。其实就是更新缓存项的Flag。InvalidateAcknowledge:响应消息,收到Invalidate消息的处理器必须回复这条消息,表示已经删除了其缓存中数据的对应副本。ReadInvalidate:请求消息,该消息是由Read和Invalidate消息组成的复合消息,主要用于通知其他处理器当前处理器准备更新一个数据,请求其他处理器删除自己缓存中对应的数据副本。接收此消息的处理器必须使用读取响应和无效确认消息进行回复。Writeback:请求报文,报文中包含需要写入主存的数据及其对应的内存地址。了解了基本的消息类型之后,我们再来看看MESI协议是如何辅助处理器实现内存读写的。看图说吧:比如:如果内存地址0xxx上的变量s是CPU1和CPU2共享的,我们先说当cache中有有效数据在下位CPU读取数据时:CPU1会根据内存地址0xxx在cache中找到对应的cacheentry,并读取cacheentry的Tag和Flag值。如果此时缓存项的Flag为M、E、S三种状态中的任意一种,则直接从缓存行中读取地址0xxx对应的数据,而不向总线发送任何报文。当缓存中不存在有效数据时:1.当在CPU2的缓存中查到的缓存项状态为1时,说明此时CPU2的缓存中没有数据s的有效数据副本。2、CPU2向总线发送Read报文,读取地址0xxx对应的数据。3、CPU1(或主存)嗅探到Read报文时,需要回复ReadResponse,提供相应的数据。4.CPU2收到ReadResponse报文后,会将其中携带的数据s存放到对应的cacheline中,并将对应的cacheentry的状态更新给S。从宏观上看,就是上面的过程。下面再深入一点,看看当缓存项为I时,消息是如何处理的。读完数据,再来说说CPU1是如何为地址为0xxx的数据s写一个MESI协议解决缓存一致性问题的,但是有一个在继续下一步之前需要等待所有其他处理器回复的问题。这种等待显然是不能接受的。下面继续看看大神们是如何解决处理器等待的问题的。3.写缓冲区和失效队列因为MESI本身有问题,它在写内存操作时必须等待所有其他处理器删除自己缓存中相应的数据副本,并收到这些处理器的Invalidate回复。只有在Acknowledge/ReadResponse消息之后才能将数据写入缓存。为了避免这种等待带来的写操作延迟,硬件设计引入了写缓冲区和失效队列。写缓冲区(StoreBuffer)在每个处理器中都有自己独立的写缓冲区。写缓冲区包含很多条目(Entry),写缓冲区比缓存小。那么,引入写缓冲区后,处理器在写入数据时会做什么呢?它还会直接向总线发送消息吗?我们来看几个场景:(注意,x86处理器会直接将每次写操作的结果存入写缓冲区,而不管对应的缓存项的状态如何)1.如果缓存项的状态为E或M在thistime:表示此时处理器已经获取了数据的所有权,然后数据会直接写入对应的cacheline,而无需向总线发送消息。2.如果此时缓存条目的状态为S,处理器会将写操作的数据存入写缓冲区的条目中,并发送Invalidate消息。如果此时对应缓存表项的状态为I,则表示写操作遇到了写未命中(WriteMiss)。这时候数据会先写入到writebuffer的入口,然后发送ReadInvalidate通知其他处理器我要更新数据了。当数据写入缓冲区时,处理器的写操作才真正完成。处理器不需要等待其他处理器返回InvalidateAcknowledge/ReadResponse消息。当处理器收到其他处理器对同一个缓存条目InvalidateAcknowledge报文的回复时,写缓冲区中对应的数据就会被写入对应的缓存行中。从上面的场景描述中,我们可以看出写缓冲区帮助处理器实现了异步写入数据的能力,从而使处理器处理命令的能力有了很大的提升。无效队列(InvalidateQueue)实际上当处理器收到Invalidate类型的消息时,并不会删除消息中指定地址对应的数据副本(即不会立即修改缓存条目的状态为I),但在消息存入失效队列后,回复InvalidateAcknowledge消息。主要原因是为了减少处理器的等待时间。所以不管是写缓冲区还是失效队列,其实都是为了减少处理器的等待时间,用空间换时间的方式来实现命令的异步处理。简而言之,写缓冲区解决了写入数据时等待其他处理器响应的问题,而失效队列帮助解决了等待删除数据的问题。但是既然是异步的,那么必然会带来新的问题——内存重排序和可见性问题。那么,让我们继续谈谈吧。存储转发(StoreForwarding)通过上面的内容,我们知道有了写缓冲区,处理器在写入数据时直接写入缓冲区,直接返回。那么问题来了,当我们写完一条数据,想要马上读取时,应该怎么办呢?话不多说,举个例子,如图:此时第一步处理器将变量S的更新数据写入写缓冲区并返回,然后立即执行第二步写S变量读取。由于此时处理器对S变量的更新结果还在writebuffer中,所以从cacheline读取的数据仍然是变量S的旧值。为了解决这个问题,storeforwarding的概念(商店转发)推出。理论上是处理器在执行读操作时会先根据对应的内存地址从写缓冲区中查询。如果找到则直接返回,否则处理器会从缓存中查找。这种从缓冲区读取的技术称为存储转发。看图:内存重排序和可见性问题由于写缓冲区和失效队列的出现,处理器执行变成了异步操作。缓冲区是每个处理器私有的,一个处理器存储的内容不能被其他处理器读取。例如:CPU1将变量更新到缓冲区,而CPU2由于无法读取CPU1缓冲区的内容,仍然从缓存中读取变量的旧值。其实这就是导致StoreLoad重排序问题的writebuffer,writebuffer也会导致StoreStore重排序问题。为了让运行在一个处理器上的线程更新共享变量以供运行在另一个处理器上的线程读取,我们必须将写缓冲区的内容写入另一个处理器的缓存,从而使缓存保持一致,这个更新可以被读取由其他处理器在性协议的作用下。当写缓冲区已满并执行I/O指令时,处理器会将写缓冲区的内容写入缓存。但从变量更新的角度来看,处理器本身并不能保证这次更新的“及时性”。为了保证处理器对共享变量的更新能够被其他处理器同步,编译器等底层系统通过一类称为内存屏障的特殊指令来实现。内存屏障中的存储屏障(StoreBarrier)会导致执行指令的处理器将写缓冲区的内容写入缓存。内存屏障中的加载屏障(LoadBarrier)会根据失效队列内容指定的内存地址,将相应处理器上缓存中相应缓存条目的状态标记为I。4.内存屏障因为我们谈到了存储屏障(StoreBarrier)和加载屏障(LoadBarrier),所以这里简单提一下内存屏障的概念。关注点:(你的详细信息)处理器支持的哪些内存重排序(LoadLoadreordering,LoadStorereordering,StoreStorereordering,StoreLoadreordering)会提供相应的可以禁止重排序的指令,而这些指令被称为内存屏障(LoadLoadbarrier,LoadStore屏障、StoreStore屏障、StoreLoad屏障)。如果使用X和Y代替Load或Store,则此类指令的作用是禁止指令左侧的任何X操作与指令左侧的任何Y操作之间的Reordering(即交换位置)进行交互指令右侧确保指令左侧的所有X操作优先于指令右侧的Y操作。内存屏障的具体作用:5.总结其实从头到尾,你会发现一个技术点的出现,往往是为了填另一个坑。为了解决处理器和主存之间的速度差距,引入了高速缓存,这就导致了缓存一致性的问题。为了解决缓存一致性问题,引入了MESI等技术,导致了处理器等待的问题。服务器等待问题引入了写入缓冲区和失效队列,从而导致重新排序和可见性问题。为了解决reordering和visibility的问题,引入了memorybarrier,很舒服。..
