本文主要介绍与多线程编程紧密相关的硬件基础知识。内容涉及:
- 高速缓存
- 缓存一致性协议--MESI协议
- 写缓冲器和无效化队列
- 指令重排序与可见性分析
- 内存屏障
一、高速缓存
我们知道CPU的处理能力要远比内存强,主内存执行一次内存读、写操作的时间可能足够处理器执行上百条的指令。为了弥补处理器与内存处理能力之间的鸿沟,在内存和处理器之间引入了高速缓存(Cache)。高速缓存是一种存取速率远比主内存大而容量远比主内存小的存储部件,每个处理器都有其高速缓存。如下图所示:
Processor 0 和 Processor 1 是两个处理器,其中分别包含一个高速缓存,高速缓存通过互联通道(系统总线、内存总线等)与DRAM(主内存)打交道。
高速缓存中有一系列缓存条目。类似于如下(不用去管桶是什么):
缓存条目的结构如下:
其中,Data Block也被成为缓存行(Cache Line),Tag则包含了缓存行中数据相应的内存地址的部分信息。Flag用于表示相应缓存行的状态信息。从代码的角度看,一个缓存行可以存储若干个变量的值。
处理器在执行内存访问操作时会将相应的内存地址解码,然后找到相应的缓存行。如果能够找到相应的缓存行并且Flag为有效时,就称相应的内存操作产生了缓存命中,否则,就称相应的内存操作产生了缓存未命中。
缓存未命中不是我们希望的,举个例子,当读未命中时,处理器所需要的数据会从主内存中加载并被存入相应的缓存行中,这个过程会导致处理器停顿而不能执行其他命令。但同时,由于高速缓存的容量远小于主内存,所以主内存中的所有数据不可能都在高速缓存中,因此缓存未命中时不可避免的。
现代处理器一般有多个层次的缓存,如下图所示:
二、缓存一致性协议----MESI协议
由于现在一般是多核处理器,每个处理器都有自己的高速缓存,那么会导致一些问题:
当某一个数据在多个处于“运行”状态的线程中进行读写共享时(例如ThreadA、ThreadB和ThreadC),第一个问题是多个线程可能在多个独立的CPU内核中“同时”修改数据A,导致系统不知应该以哪个数据为准;第二个问题是由于ThreadA进行数据A的修改后没有即时写会内存ThreadB和ThreadC也没有即时拿到新的数据A,导致ThreadB和ThreadC对于修改后的数据不可见。这就是缓存一致性问题。
为了解决这个问题,处理器之间需要一种通信机制----缓存一致性协议。
MESI(Modified-Exclusive-Shared-Invalid)协议是一种广为使用的缓存一致性协议。MESI协议对内存数据访问的控制类似于读写锁,它使得针对同一地址的读内存操作是并发的,而针对同一地址的写内存操作是独占的。
MESI协议的四种状态:
M(修改,Modified):本地处理器已经修改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此 cache 只有本地一个拷贝(专有);
E(专有,Exclusive):缓存行内容和内存中的一样,而且其它处理器都没有这行数据;
S(共享,Shared):缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝;
I(无效,Invalid):缓存行失效, 不能使用。
注:在MESI协议中,每个Cache的Cache控制器不仅知道自己的读写操作,而且也监听(snoop)其它Cache的读写操作。
另外,为了缓存之间的通讯,协调各个处理器的读、写内存操作,MESI协议还定义了下面的一组消息:
(过一眼就行,不用深追每一个的细节)
下面我们说一下如何使用MESI协议进行读或写内存的大致流程,然后再举一个具体的例子
并发读:
当处理器Processor 0要读取缓存中的数据S时,如果发现S所在的缓存条目状态为M、E或S,那么处理器可直接读取数据。
如果S所在的缓存条目状态状态为 I,说明Processor 0的缓存中不包含S的有效数据。这时,Processor 0会往总线发送一条Read消息来读取S的有效数据,而缓存状态不为 I 的其他处理器(如Process 1)或主内存(其他处理器缓存条目状都为 I 时从主内存读)收到消息后需要回复Read Response,来将有效的S数据返回给发送者。
需要注意的是,返回有效数据的其他处理器(如Process 1),如果状态为M,则会先将数据写入主内存,此时状态为E,然后在返回Read Response后,再将状态更新为S。
这样,Processor 0读取的永远是最新的数据,即使其他处理器对这个数据做了更改,也会获取到其他处理器最新的修改信息。
互斥写:
互斥写
当处理器Processor 0要向地址A中写数据时,如果地址A所在的缓存条目状态为E、M,说明Processor 0已拥有该数据的独占权,Processor 0可直接将数据写入A,然后将缓存条目状态改为M
如果写的缓存条目状态为S,处理器Processor 0需要往总线发送Invalidate消息来获取该缓存条目的独占权,当接收到其他所有处理器返回的Invalidate Acknowledge消息后,Processor 0才会确定自己已获得独占权,然后再将数据更新到地址A中,并将对应的缓存条目状态改为M
如果写的缓存条目状态为I,处理器Processor 0需要往总线发送Read Invalidate消息来获取该缓存条目的独占权,其他步骤同S
需要注意的是,如果接收到Invalidate消息的其他其他处理器,缓存条目状态为M,则该处理器会先将数据写入主内存(以方便发送Read Invalidate指令的处理器读到最新值),然后再将状态改为I。
这样,Processor 0与其他处理器写的时候,永远只有一个处理器能够获得独占权,即实现了互斥写。
举例说明:
图片精简描述,不代表Cache内部真实构造。
(1)初始时:处理器0 和处理器1的变量a的值都为3,和主内存一致。同时缓存条目状态为S。
(2)处理器0修改a的值为4,需要往总线发送Invalidate消息,使其他处理的对应数据的缓存条目状态变为I。此时主内存还没被修改。
(3)其他处理器回复Invalidate Acknowledge消息。此时处理器0获得此数据的独占权。写到内存,并将缓存条目的状态修改为M。
注:MESI这部分知道有这些概念,通信机制即可。知道MESI是用来帮助实现数据一致性的即可。其他的内存操作情况就不介绍了。
所以每个缓存行所处的状态根据本处理器和其它处理器的读写操作在4个状态间进行迁移,如下图:
其中local read和local write代表当前处理器读或写。remote read和remote write代表其他处理器读或写。(这幅图没理解也没关系,不影响整体目标)
三、写缓冲器和无效化队列
依照上面的MESI协议,多线程并发访问同一个共享变量时,并发读和互斥写,应该是已经解决了数据一致性问题,那为什么我们编程中还是会出现 “可见性” 这样线程不安全的问题呢?
原因在于写缓冲器和无效化队列的引入。MESI协议虽然解决了缓存一致性问题,但其本身有一个性能缺陷:处理器每次写数据时,都得等待其他所有处理器将其高速缓存中对应的数据删除,并接收到它们返回的Read Response与Invalidate Acknowledge消息后才执行写操作。这个过程无疑是很消耗时间的。
所以硬件设计者,解决了缓存一致性问题后,为了解决新出现的性能问题,又引入了新的部件:写缓冲器和无效化队列。
3.1 写缓冲器
写缓冲器是处理器内部一个容量比高速缓存还小的高速存储部件,每个处理器都有自身的写缓冲器,写缓冲器包含若干个条目,且一个处理器无法读取另一个处理器上的写缓冲器内容。写缓冲器的引入主要是为了解决上面提到的MESI的写延迟问题。(记住黑体的部分,这是后面产生问题的原因)。
①写操作过程
引入写缓冲器后,当处理器要写入数据时:
如果相应的缓存条目状态为 E、M,则直接写入,无需发送消息(照旧)
如果相应的缓存条目状态为 S, 处理器会将写操作相关信息存入写缓冲器,并发送Invalidate消息。(不再等待响应消息)
如果相应的缓存条目状态为 I,发生“写未命中”,将写操作相关信息存入写缓冲器,并发送Read Invalidate消息。(不再等待响应消息)
当处理器将写操作写入写缓冲器后,则认为写操作已经完成。而实际上,当处理器收到其他所有处理器回应的Read Response、Invalidate Acknowledge消息后,处理器才会将写缓冲器中对应的写操作写入相应的缓存行,这个时候,写操作才算真正完成。
写缓冲器让处理器在执行写操作时不需要再额外的等待,减少了写操作的延时,提高了处理器的指令执行效率。
②读操作过程
引入写缓存器后,处理器读取数据时,由于该数据的更新结果可能仍然停留在写缓冲器中,所以处理器会先从写缓冲器中找寻数据,没有找到时,才从高速缓存中找。
这种处理器直接从写缓冲器中读取数据的技术被称为:存储转发。
3.2 无效化队列
在引入无效化队列后,处理器在接收到Invalidate消息后,并不马上删除消息中指定地址对应的副本数据,而是将消息存入无效化队列之后就回复Invalidate Acknowledge消息,从而减少了执行写操作的处理器的等待时间。(需要注意的是,有些处理器(如X86)可能并没有使用无效化队列)
3.3 写缓冲器和无效化队列
写缓冲器和无效化队列的引入带来了性能的提高,同时又带来了新的两个问题:内存重排序与可见性。
1.重排序问题
考虑如下执行序列
变量data初始值为0,变量ready初始值为false。两个处理器Processor 1和Processor 2在各自的线程上执行上述代码。执行的绝对时间顺序为 S1——>S2——>L3——>L4。
- 以StoreStore(写--写)操作为例,看写缓冲造成的重排序
如果S1步data值的写操作被写入写缓冲器、还没真正的写到高速缓存中,而S2步的ready值的写操作已经写入到了高速缓存。那在L3步读取ready值时,根据MESI协议,会读到正确的ready值:true;但在L4步读取data时,会读到data的初始值0,而不是在另外一个处理器写缓冲器中的值:修改值1
在处理器Processor 2看来,S1和S2的执行顺序就好像反了一样,即发生了重排序。
2.可见性问题:
一个处理器的写缓冲器中的内容是无法被其他处理器读取的,这个也就造成了一个处理器更新一个共享变量后,对其他处理器而言,看不到这个更新的值,即可见性。写缓冲器是可见性问题的硬件根源。
四、内存屏障
为了解决写缓冲器和无效化队列带来的可见性和重排序问题,硬件设计者又推出了新的方案:内存屏障。
内存屏障是被插入两个CPU指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将写缓冲器的值写入高速缓存,清空无效队列,从而“附带”的保障了可见性。
具体来说,处理器支持哪种内存重排序(LoadLoad重排序,LoadStore重排序,StoreStore重排序,StoreLoad重排序),就会提供禁止相应重排序的指令,这些指令就被称为基本内存屏障。
内存屏障是实现volatile、synchronized关键字的底层原理。关于这两个关键字,后面的文章再讲。
参考资料
https://blog.csdn.net/yinwenjie/article/details/83069483
《Java多线程编程实战指南》
网友评论