一、高速缓存
高速缓存是一种存取速率远比主内存大,而容量远比主内存小的处理器
存储部件。引入高速缓存之后,处理器在执行内存读、写操作的时候并不直接与主内存打交道,而是通过高速缓存进行的。变量名相当于内存地址,而变量值则相当于相应内存空间所存储的数据。
高速缓存又可以分为指令缓存和数据缓存。指令缓存用来缓存程序的代码,数据缓存用来缓存程序的数据。
- 【一级缓存(L1 Cache)】本地 core 的缓存,分成 32K 的数据缓存 L1d 和 32k 指令缓存 L1i,访问 L1 需要 3 cycles,耗时大约 1ns。
- 【二级缓存(L2 Cache)】本地 core 的缓存,被设计为 L1 缓存与共享的 L3 缓存之间的缓冲。大小为 256K,访问 L2 需要 12 cycles,耗时大约 3ns。
- 【三级缓存(L3 Cache)】在同插槽的所有 core 共享 L3 缓存,分为多个 2M 的段,访问 L3 需要 38 cycles,耗时大约 12ns。
一级缓存可能直接被集成在处理器的内核里,因此访问效率非常高。通常包括两部分,一部分用于存储指令,一部分用于存储数据,距离处理器越近的高速缓存,存储速率越快,制造成本越高,容量越小。
从内部结构来看高速缓存相当于一个拉链散列表(ChainedHash Table)。它包含若干桶(Bucket,硬件上称为 Set),每个桶又包含若干缓存条目(CacheEntry)。- Tag 包含了与缓存行中数据相应的内存地址的部分信息(内存地址的高位部分比特)。
- Data Block 被称为缓存行(CacheLine),它是高速缓存与主内存之间的数据交换最小单元,用于存储从内存中读取的或者准备写往内存的数据。
- Flag 用于表示相应缓存行的状态信息。缓存行的容量(也被称为缓存行宽度)通常是 2 的倍数,其大小在 16-256 字节/Byte之间不等。一个缓存行可以存储若干变量的值, 而多个变量的值则可能被存储在同一个缓存行之中。
处理器在执行内存访问操作时会将相应的内存地址解码。内存地址的解码结果包括 index、tag 以及 offset 这三部分数据。
- index 相当于桶编号,它可以用来定位内存地址对应的桶。
- 一个桶可能包含多个缓存条目。tag 相当于缓存条目的相对编号,其作用在于用来与同一个桶中的各个缓存条目中的 Tag 部分进行比较,以定位一个具体的缓存条目。
- 一个缓存条目中的缓存行可以用来存储多个变量,offset 是缓存行内的位置偏移,其作用在于确定一个变量在一个缓存行中的存储起始位置。
二、缓存命中
根据这个内存地址的解码结果,如果高速缓存子系统能够找到相应的缓存行并且缓存行所在的缓存条目的 Flag 表示相应缓存条目是有效的,那么就称相应的内存操作产生了缓存命中(CacheHit);否则,就称相应的内存操作产生了缓存未命中(CacheMiss)。
1从性能角度来看,减少缓存未命中
缓存未命中包括读未命中(Read Miss)和写未命中(Write Miss),分别对应内存读和写操作。当读未命中产生时,处理器所需读取的数据会从主内存中加载并被存入相应的缓存行之中。这个过程会导致处理器停顿(Stall)而不能执行其他指令,这不利于发挥处理器的处理能力。
2缓存未命中不可避免
由于高速缓存的总容量远小于主内存的总容量,同一个缓存行在不同时刻存储的可能是不同的一段数据,因此缓存未命中是不可避免的。
在 Linux 系统中,可以使用 Linux 内核工具 perf 来查看程序运行过程中的缓存未命中情况。三、缓存一致性协议
1️⃣缓存一致性问题
多个线程并发访问同一个共享变量的时候,这些线程的执行处理器上的高速缓存各自都会保留一份该共享变量的副本,这就带来一个问题一个处理器对其副本数据进行更新之后,其他处理器如何“察觉”到该更新并做出适当反应,以确保这些处理器后续读取该共享变量时能够读取到这个更新
,这就是缓存一致性问题。 例如:CPU-0 读取主存 count 的数据为 1,缓存到 CPU-0 的高速缓存中。CPU-1 也做了同样的事情,而 CPU-1 把 count 的值修改成了 2,并且同步到 CPU-1 的高速缓存,但是这个修改以后的值并没有写入到主存中,CPU-0 访问该字节,由于缓存没有更新,所以仍然是之前的值,就会导致数据不一致的问题。
2️⃣实质就是如何防止读脏数据和丢失更新的问题。
-
【总线锁】
总线锁就是用来锁住总线的。可以通过图来了解总线在这个场景中所处的位置。当一个 CPU 核执行一个线程对其缓存中的数据进行操作的时候,它会向总线上发送一个 Lock 信号,此时其他的线程想要去请求主内存的时候,就会被阻塞,这样该处理器核心就可以独享这个共享内存。总线锁相当于把内存和 CPU 之间的通信锁住,把并行化的操作变成了串行,这会导致 CPU 的性能下降,这与需要多核多线程并行操作来提高程序的效率的目的大相径庭。所以 P6 系列以后的处理器,出现了另外一种方式,就是缓存锁。
【缓存锁】如果某个内存区域数据,已经同时被两个或以上处理器核缓存,缓存锁就会通过缓存一致性机制阻止对其修改,以此来保证操作的原子性,当其他处理器核回写已经被锁定的缓存行的数据时会导致该缓存行无效。就是说当某块 CPU 核对缓存中的数据进行操作了之后,就通知其他 CPU 放弃储存在它们内部的缓存,或者从主内存中重新读取。
处理器上有一套完整的协议,来保证缓存的一致性,比较经典的就是 MESI 协议,其实现方法是在 CPU 缓存中保存一个标记位,以此来标记四种状态。另外,每个 Core 的 Cache 控制器不仅知道自己的读写操作,也监听其它 Cache 的读写操作,就是嗅探(snooping)协议。
- Modified(更改过的,记为 M):处于这一状态的数据,只在本 CPU 核中有缓存数据,而其他核中没有。同时其状态相对于内存中的值来说,是已经被修改的,只是没有更新到内存中。
- Exclusive(独占的,记为 E):处于这一状态的数据,只有在本 CPU 中有缓存,且其数据没有修改,即与内存中一致。
- Shared(共享的,记为s):处于这一状态的数据在多个 CPU 中都有缓存,且与内存一致。
- Invalid(无效的,记为I):本 CPU 中的这份缓存已经无效。
CPU的读取会遵循几个原则(其实就是上面说的嗅探)
一个处于 M 状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回 CPU。
一个处于 S 状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为 I。
一个处于 E 状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为 S。
当 CPU 需要读取数据时,如果其缓存行的状态是 I 的,则需要从内存中读取,并把自己状态变成 S,如果不是 I,则可以直接读取缓存中的值,但在此之前,必须要等待其他 CPU 的监听结果,如其他 CPU 也有该数据的缓存且状态是 M,则需要等待其把缓存更新到内存之后,再读取。
当 CPU 需要写数据时,只有在其缓存行是 M 或者 E 的时候才能执行,否则需要发出特殊的 RFO 指令(Read Or Ownership,这是一种总线事务),通知其他 CPU 置缓存无效(I),这种情况下性能开销是相对较大的。在写入完成后,修改其缓存状态为 M。
所以如果一个变量在某段时间只被一个线程频繁地修改,那么使用其内部缓存就完全可以了,并不需要涉及到总线事务。如果内存一会被这个 CPU 独占,一会被那个 CPU 独占,这时才会不断产生 RFO 指令影响到并发性能。这其实是跟 CPU 协调机制有关,如果在 CPU 间调度不合理,会形成 RFO 指令的开销比任务开销还要大,喧宾夺主,我们反而不能提高效率。
并非所有情况都会使用缓存一致性的,如被操作的数据不能被缓存在 CPU 内部或操作数据跨越多个缓存行(状态无法标识),则处理器会调用总线锁定;另外当 CPU 不支持缓存锁定时,自然也只能用总线锁定了,比如说奔腾 486 以及更老的 CPU。
四、MESI 协议的处理器读写操作?
1️⃣Processor 0 上读取数据 S 的实现
设内存地址 A 上的数据 S 是处理器 Processor 0 和处理器 Processor l 可能共享的数据。
Processor 0 会根据地址 A 找到对应的缓存条目,并读取该缓存条目的 Tag 和 Flag 值(缓存条目状态)。为讨论方便,这里不讨论 Tag 值的匹配问题。Processor 0 找到的缓存条目的状态如果为 M、E 或者 S,那么该处理器可以直接从相应的缓存行中读取地址 A 所对应的数据,而无需往总线中发送任何消息。Processor 0 找到的缓存条目的状态如果为 I,则说明该处理器的高速缓存中并不包含 S 的有效副本数据,此时 Processor 0 需要往总线发送 Read 消息以读取地址 A 对应的数据,而其他处理器 Processor l (或者主内存)则需要回复 ReadResponse 以提供相应的数据。
Processor 0 接收到 ReadResponse 消息时,会将其中携带的数据(包含数据 S 的数据块) 存入相应的缓存行并将相应缓存条目的状态更新为 S。Processor 0 接收到的 Read Response 消息可能来自主内存也可能来自其他处理器(Processor I)。
Processor I 会嗅探总线中由其他处理器发送的消息。Processor I 嗅探到 Read 消息的时候,会从该消息中取出待读取的内存地址,并根据该地址在其高速缓存中查找对应的缓存条目。如果 Processor I 找到的缓存条目的状态不为 I (如图),则说明该处理器的高速缓存中有待读取数据的副本,此时 Processor l 会构造相应的 ReadResponse 消息并将相应缓存行所存储的整块数据(而不仅仅是 Processor 0 所请求的数据S)塞入该消息。如果 Processor 1 找到的相应缓存条目的状态为 M,那么 Processor 1 可能在往总线发送 ReadResponse 消息前将相应缓存行中的数据写入主内存。Processor 1 往总线发送 ReadResponse 之后,相应缓存条目的状态会被更新为 S。如果 Processor I 找到的高速缓存条目的状态为 I,那么 Processor 0 所接收到的 ReadResponse 消息就来自主内存。
可见,在 Processor 0 读取内存 的时候,即便 Processor I 对相应的内存数据进行了更新且这种更新还停留在 Processor I 的高速缓存中而造成高速缓存与主内存中的数据不一致,在 MESI 协议消息的协调下这种不一 致也并不会导致 Processor 0 读取到一个过时的旧值。
2️⃣Processor 0 往地址 A 写数据的实现
任何一个处理器执行内存写操作时必须拥有相应数据的所有权。在执行内存写操作时,Processor 0 会先根据内存地址 A 找到相应的缓存条目。Processor 0 所找到的缓存条目的状态若为 E 或者 M,则说明该处理器已经拥有相应数据的所有权,此时该处理器可以直接将数据写入相应的缓存行并将相应缓存条目的状态更新为 M。Processor 0 所找到的缓存条目的状态如果不为 E、M,则该处理器需要往总线发送 Invalidate 消息以获得数据的所有权。其他处理器接收到 Invalidate 消息后会 将其高速缓存中相应的缓存条目状态更新为 I(相当于删除相应的副本数据)并回复 Invalidate Acknowledge 消息。发送 Invalidate 消息的处理器(即内存写操作的执行处理器),必须在接收到其他所有处理器所回复的所有 I nvalidate Acknowledge 消息之后再将数据更 新到相应的缓存行之中。Processor 0 所找到的缓存条目的状态若为 s,则说明 Processor l 上的高速缓存可能也保留了地址 A 对应的数据副本(场景 I),此时 Processor 0 需要往总线发送 Invalidate 消息。Processor 0 在接收到其他所有处理器所回复的 InvalidateAcknowledge 消息之后会将相应的缓存条目的状态更新为 E,此时 Processor 0 获得了地址 A 上数据的所有权。 接着,Processor 0 便可以将数据写入相应的缓存行,并将相应缓存条目的状态更新为 M 。Processor 0 所找到的缓存条目的状态若为 I,则表示该处理器不包含地址 A 对应的有效副本数据(场景 2),此时 Processor 0 需要往总线发送 Read Invalidate 消息。Processor 0 在接收到 Read Response 消息以及其他所有处理器所回复的 Invalidate Acknowledge 消息之后,会将相应缓存条目的状态更新为 E,这表示该处理器已经获得相应数据的所有权。接 着,Processor 0 便可以往相应的缓存行中写入数据了并将相应缓存条目的状态更新为 M。其他处理器在接收到 Invalidate 消息或者 Read Invalidate 消息之后,必须根据消息中包含的内存地址在该处理器的高速缓存中查找相应的高速缓存条目。若 Processor I 所找到的高速缓存条目的状态不为 I (场景 2),那么 Processor I 必须将相应缓存条目的状态更新为 I,以删除相应的副本数据并给总线回复 Invalidate Acknowledge 消息。可见 Invalidate 消息和 Invalidate Acknowledge 消息使得针对同一个内存地址的写操作在任意一个时刻只能由一个处理器执行,从而避免了多个处理器同时更新同一数据可能导致的数据不一致问题。
从上述例子来看,在多个线程共享变抵的情况下,MESI 协议已经能够保障一个线程对共享变量的更新对其他处理器上运行的线程来说是可见的。既然如此,可见性又何以存在呢?这就要从写缓冲器和无效化队列的角度来解释了。