概述

这篇笔记记录了多线程编程中的硬件基础。从处理器的存储系统出发,在硬件层面解释了多线程编程中的可见性、有序性问题产生的原因及解决方案中的硬件支持。


高速缓存

引入高速缓存的原因

现代处理器处理能力要远远胜于主内存的访问速率。为了弥补处理器与主内存之间速率的鸿沟,在他们之间引入了高速缓存(Cache)。
高速缓存是一种存取速率远比主存快,但容量远远小于主存的存储部件。每个处理器都有其高速缓存。引入高速缓存后,处理器在执行内存读、写操作的时候并不直接与主存打交道,而是通过高速缓存。高速缓存相当于为程序所访问的每个变量保留了一份相应内存空间所存储数据(变量值)的副本。

高速缓存结构

我们可以把高速缓存看成一个容量极小的由硬件实现的散列表(Hash Table)。它的key是一个内存地址,它的value是内存数据的副本或者准备写入内存的数据。它包含若干桶,每个桶又可以包含若干缓存条目,缓存条目又称为缓存行(Cache Line),一个缓存行可以存储若干变量的值。如下图:

javafx双缓存 java高速缓存_javafx双缓存

其中每个缓存条目又可以分成三个部分:Tag、Data Block及Flag:

处理器在执行内存访问操作的时候会将相应的内存地址解码(具体动作由高速缓存控制器执行)。解码后得到三部分数据:tag、index及offset。其中index相当于散列表中桶的编号,tag相当于缓存条目的相对编号,定位过程可以类比HashMap。由于一个缓存条目中可以存储多个变量值,offset即是作为缓存行内的位置偏移值,用于定位数据在缓存行中的具体定位。
现代处理器一般具有多个层次的高速缓存。


缓存一致性协议

概述

缓存一致性协议(Cache Coherence Protocol)是为了解决处理器无法访问读取到其他处理器上对共享变量更新的问题。
在X86处理器上使用的缓存一致性协议是基于MESI(Modified-Exclusive-Shared-Invalid)协议的。MESI协议对内存数据访问的控制类似于读写锁,它使得对同一地址的读内存操作是并发的,而对同一地址的写内存操作是独占的。
MESI将缓存条目的状态分为Modified、Exclusive、Shared、Invalid四种,并在这基础上定义了一组消息(Message)用于协调各个处理器的读写内存操作:

  • Modified(更改过的,记为M):该状态表示相应缓存行包含对应内存地址所做的更新结果数据。任意时刻,多个处理器上的高速缓存中Tag值相同的缓存条目中,只能有一个缓存条目处于该状态。
  • Exclusive(独占的,记为E):该状态表示相应缓存行包含对应内存地址所对应的副本数据,并且其他所有处理器上都不保留该数据的有效副本
  • Shared(共享的,记为S):该状态表示相应缓存行包含相应内存地址所对应的副本数据。且其他处理器上的高速缓存中Tag值与这个相同的缓存条目状态也为Shaerd。
  • Invalid(无效的,记为1):该状态表示相应缓存行中不包含内存地址对应的任何有效副本数据。这个状态是缓存条目的初始状态。

MESI定义了一组消息用于协调各个处理器的读写操作。处理器在执行读、写操作时必要的情况下会往总线(Bus)中发送特定的请求消息,同时每个处理器还嗅探(Snoop,也称为拦截)总线中其他处理器发送的消息并在特定情况下回复:

消息名

消息类型

描述

Read

请求

通知其他处理器、主存,当前处理器准备读取某个数据。

Read Response

响应

该消息包含被请求读取的数据。可能来自主存,也可能来自其他处理器的高速缓存。

Invalidate

请求

通知其他处理器将其高速缓存中指定内存地址的缓存条目置为I,即通知他们删除指定数据。

Invalidate Acknowledge

响应

接收到Invalidate的处理器必须回复该消息,以表示删除了响应的副本数据。

Read Invalidate

请求

由Read消息和Invalidate消息组合而成。用于通知其他处理器当前处理器准备更新(Readd-Modify-Write,读后写更新)一个数据,并请求其他处理器删除其高速缓存中相应的副本数据。收到该消息的处理器必须回复Read Response和Invalidate Acknowledge消息

Writeback

请求

将高速缓存中的数据写入主存中。

要注意的是,Read Response中并不仅仅包含请求的数据,而是该数据所在缓存行的所有数据。


写缓冲器与无效化队列

MESI协议已经能够保障一个线程对共享变量的更新对其他处理器上运行的线程来说是可见的。那多线程中的三个问题之一的可见性是怎么导致的呢?由于处理器在写内存时,必须收到其他处理器回复的Read Response/Invalidate Acknowledge消息之后才能将数据写入高速缓存中,为了减少这种延迟,引入了写缓冲器(Store Buffer,或称为Write Buffer)和无效化队列(Invalidate Queue)。写缓冲器便是可见性问题的硬件根源。

写缓冲器

写缓冲器是一个容量比高速缓存还小的私有高速存储部件,每个处理器都有其写缓冲器。一个处理器无法读取另一个处理器上的写缓冲器的内容(高速缓存的读取可以通过MESI协议,但是写缓冲器无法互相读取)。

引入写缓冲器之后的写操作流程:

javafx双缓存 java高速缓存_缓存_02

存储转发(Store Forwarding)

引入写缓冲器之后,处理器在执行读操作的时候不能根据对应的内存地址直接读取相应缓存行中的数据作为该操作的结果。因为一个处理器在更新一个变量后紧接着又读取该变量的值。由于该处理器先前对该变量的更新结果可能还停留在写缓冲器中,因此该变量相应的内存地址所对应的缓存行中存储的值是该变量的旧值。因此为了避免这种情况,处理器在执行读操作时会先查询写缓冲器。这种结束被称为存储转发

无效化队列

无效化队列的作用在于,处理器在接收到Invalidate消息后并不删除消息中指定地址对应的副本数据,而是将消息存入无效化队列之后就回复Invalidate Acknowledge消息,从而减少了写操作执行处理器所需的等待时间。

由写缓冲器与无效化队列导致的多线程问题

由写缓冲器与无效化队列导致的多线程问题包括:1. 内存重排序问题;2.可见性问题:

  • 内存重排序问题
    写缓冲器可能导致StoreLoad(Stores Reordered After Loads)、StoreStore(Stores Reordered After Stores)重排序。无效化队列可能导致LoadLoad(Loads Reordered After Loads)重排序。
  • StoreLoad重排序:

P0

P1

X = 1; //S1

Y = 1; //S3

r1 = Y; //L2

r2 = X; //L4

假设P0、P1上两个线程未使用任何同步措施而各自按照程序顺序依照上面表格交错执行。其中X、Y为共享变量,初始值都为0,r1、r2为局部变量。当P0执行到L2时,P1上S3的结果可能还停留在P1的写缓冲器中,因此P0读取到的仍是Y的初始值0。同样,当P1执行到L4时,P1读取到的X值也可能是初始值0。因此从P1的角度看,P0执行了L2,而S1却像是尚未被执行,即P1对P0的感知顺序是L2->S1,发生了StoreLoad重排序。

  • StoreStore重排序:

P0

P1

data = 1; //S1

ready = true; //S2

while(!ready) continue; //L3

print(data); //L4

假设P0、P1上两个线程未使用任何同步措施而各自按照程序顺序依照上面表格交错执行。其中data、ready为两个共享变量,初始值分别为0、false。其中P0上包含ready的副本,且为E/M状态,但不包含data的副本。P0在执行S1时,需要先将结果写入写缓冲器中,而S2的结果会直接写入缓存行中。当P1执行到L3时,可以读取到P0对ready的更新。P1执行到L4,P0对S1的更新可能仍停留在P0的写缓冲器中,P1读取到的仍然是初始值0。从P1的角度看,就像S2先于S1执行,发生了StoreStore重排序。

  • LoadLoad重排序
    继续看上一张表,假设P0中有data和ready的副本,P1中仅有data的副本而没有ready的副本。P0执行S1,发送Invalidate消息,并将结果写入其写缓冲器中。P1收到Invalidate消息后,将该消息存入其无效化队列中,并回复Invalidate Acknowledge消息。P0继续执行S2,由于ready只存在于P0,因此P0直接将结果写入缓存中。紧接着P1执行L3,由于P1中没有ready的副本,因此会发送Read消息到总线。P0收到Read消息后,回复Read Response消息,包含ready的新值true。P1收到后从中取出ready新值。接着执行L4,此时P0发出的Invalidate消息可能仍然停留在P1的无效化队列中,P1会直接从其高速缓存中读取data的值,因此P1打印的变量可能是一个旧值。 从P0的角度看,L4被重排序到了L3之前。
  • 可见性问题
    写缓冲器与无效化队列都会导致可见性问题。因此,解决可见性问题首先要使写线程对共享变量所做的更新能够到达高速缓存,从而使该更新对其他处理器是可同步的。其次,读线程所在的处理器要将其无效化队列中的内容“应用”到其高速缓存上,这样才能够将其他处理器对共享变量所做的更新同步到该处理器的高速缓存中。
    这两点可以通过存储屏障(Store Barrier)与加载屏障(Load Barrier)成对使用来实现。关于内存屏障的具体知识会在接下来的笔记中记录。