最近,在学习 Disruptor 的时候,再次接触到这个听了很多次,但是一直不熟悉的名词————内存屏障。因为知道了内存屏障的实际应用场景,所有这次决定好好了解一下它。
要理解内存屏障,首先要了解 计算机CPU以及缓存。
一、计算机CPU以及多级缓存
现代CPU现在比现代的内存系统快得多。为了弥合这一鸿沟,CPU使用复杂的缓存系统,这些系统可以有效地快速生成硬件哈希表,而无需链接。
下面是一张从美团的技术博客————《高性能队列——Disruptor》 讲解伪共享 时的配图:
- L1、L2、L3分别表示一级缓存、二级缓存、三级缓存,越靠近CPU的缓存,速度越快,容量也越小。
- L1 缓存很小但很快,并且紧靠着在使用它的CPU内核;
- L2 大一些,也慢一些,并且仍然只能被一个单独的CPU核使用;
- L3 更大、更慢,并且被单个插槽上的所有CPU核共享;
- 最后是主存(内存),被全部插槽上的所有CPU核共享。
另外,线程之间共享一份数据的时候,需要一个线程把数据写回主存,而另一个线程访问主存中相应的数据。
二、Memory Barrier
接着就是 《Disruptor Paper》 中 Memory Barriers 一节中的论述:
这些缓存通过消息传递协议与其他处理器缓存系统保持一致。
此外,处理器具有“存储缓冲区”来卸载对这些缓存的写入,以及“使队列失效”,以便缓存一致性协议能够在即将发生写入时快速确认失效消息,从而提高效率。
读内存屏障通过在无效队列中标记一个点来指示 CPU 上的加载指令来执行它,以便更改进入其缓存。 这使它对在读取屏障之前排序的写入操作具有一致的视图。
写屏障通过在存储缓冲区中标记一个点来命令 CPU 上的存储指令执行它,从而通过其缓存刷新写出。 这个屏障提供了一个有序的视图,了解在写入屏障之前发生了什么存储操作。
在Java内存模型中,volatile字段的读写分别实现读写屏障。
读到这边,我起初还是不明白其中含义,当时只是Get 了几个关键字————缓存一致性协议,存储缓冲区,失效队列,读内存屏障,写内存屏障。
幸运的是,我不是第一个踩坑的,早有前人栽树了——
- 《什么是内存屏障? Why Memory Barriers ?》
- 《CPU缓存一致性协议MESI》
三、缓存一致性协议————MESI
MESI 协议是 Cache line 四种状态的首字母的缩写,分别是修改(Modified)态、独占(Exclusive)态、共享(Shared)态和失效(Invalid)态。 Cache 中缓存的每个 Cache Line 都必须是这四种状态中的一种。
3.1 Cache 的状态:
Cache块状态 | 详细解释 | 简要说明 |
---|---|---|
修改态(Modified) | 如果该 Cache Line 在多个 Cache 中都有备份,那么只有一个备份能处于这种状态,并且“dirty”标志位被置上。拥有修改态 Cache Line 的 Cache 需要在某个合适的时候把该 Cache Line 写回到内存中。但是在写回之前,任何处理器对该 Cache Line在内存中相对应的内存块都不能进行读操作。 Cache Line 被写回到内存中之后,其状态就由修改态变为共享态。 | 当前CPU cache拥有最新数据(最新的cache line),其他CPU拥有失效数据(cache line的状态是invalid),虽然当前CPU中的数据和主存是不一致的,但是以当前CPU的数据为准; |
独占态(Exclusive) | 和修改状态一样,如果该 Cache Line 在多个 Cache 中都有备份,那么只有一个备份能处于这种状态,但是“dirty”标志位没有置上,因为它是和主内存内容保持一致的一份拷贝。如果产生一个读请求,它就可以在任何时候变成共享态。相应地,如果产生了一个写请求,它就可以在任何时候变成修改态。 | 只有当前CPU中有数据,其他CPU中没有改数据,当前CPU的数据和主存中的数据是一致的; |
共享态(Shared) | 意味着该 Cache Line 可能在多个 Cache 中都有备份,并且是相同的状态,它是和内存内容保持一致的一份拷贝,而且可以在任何时候都变成其他三种状态。 | 当前CPU和其他CPU中都有共同数据,并且和主存中的数据一致; |
失效态(Invalid) | 该 Cache Line 要么已经不在 Cache 中,要么它的内容已经过时。一旦某个Cache Line 被标记为失效,那它就被当作从来没被加载到 Cache 中; | 当前CPU中的数据失效,数据应该从主存中获取,其他CPU中可能有数据也可能无数据,当前CPU中的数据和主存被认为是不一致的; |
上表中的详细解释摘自《什么是内存屏障? Why Memory Barriers ?》,简要说明摘自《CPU多级缓存与缓存一致性,详细的讲解》
3.2 Cache 的操作:
MESI协议中,每个cache的控制器不仅知道自己的操作(local read和local write),每个核心的缓存控制器通过监听也知道其他CPU中cache的操作(remote read和remote write),进而再确定自己cache中共享数据的状态是否需要调整。
操作类型 | 操作说明 |
---|---|
local read | 读本地cache中的数据 |
local write | 将数据写到本地cache |
remote read | 其他核心发生read |
remote write | 其他核心发生write |
在维基百科 MSI protocol,国内可能打不开维基百科链接,可以看看 TheFreeDictionary MSI protocol,对于操作的定义很详尽:
Cache块的状态从一种状态过渡到另一种状态,通常有两大类刺激因素:
- 第一个刺激因素是处理器的特定读写请求。例如:处理器P1在其缓存中有一个块X,并且有来自处理器的从该块读取或写入的请求。
- 第二个刺激因素来自另一个处理器。例如:处理器P2的缓存中没有缓存块或缓存块中不是最新数据,当前处理器P1通过连接所有处理器的总线收到来自处理器P2的“刺激”。
总线请求在侦测器(Snoopers)的帮助下被监视,侦测器监视所有总线事务。
以下是不同类型的处理器请求和总线侧请求:
- 处理器对缓存的请求包括以下操作:
- 处理器读缓存块请求(PrRd: The processor requests to read a Cache block.)
- 处理器写缓存块请求(PrWr: The processor requests to write a Cache block.)
- 总线侧请求包括以下操作:
- 总线读请求(BusRd): 表明正有其他处理器请求读取缓存块。
- 总线写请求(BusRdX):表明正有其他处理器请求写入一个它的缓存中不存在的缓存块。
- 总线更新请求(BusUpgr):表明有其他处理器请求写入一个已经保存在缓存中的缓存块。
- 总线“回写”请求(Flush):表明其他处理器正在回写一整块缓存块到主存中。
- 总线“缓存到缓存的传输”请求(FlushOpt):整个缓存块发布在总线上,以便将其提供给另一个处理器。
如果将数据块从主存转移到缓存的延迟大于从缓存转移到缓存的延迟(在基于总线的系统中通常是这样),则这种缓存到缓存的转移可以减少读未命中延迟。但在多核架构中,一致性保持在二级缓存级别,片上三级缓存可能更快从三级缓存而不是从另一个二级缓存获取丢失的块。
侦测(Snooping)操作:在侦测系统中,总线上的所有缓存监视(或侦测)所有总线事务。每个缓存都有其存储的每个物理内存块的共享状态副本。根据所用协议的状态图更改块的状态。总线的两侧都有侦测者:
- 处理器/缓存端的侦测器。
- 内存侧的监听功能由内存控制器完成。
每个缓存块都有自己的4种状态Finite State Machine(MESI)。表1.1和表1.2显示了与不同输入有关的状态转换和特定状态下的响应:
Table 1.1 状态转换和对各种处理器操作的响应
初始状态 | 处理器操作 | 响应 |
---|---|---|
Invalid(I) | PrRd | 1. 向总线发出BusRd信号; 2. 其他缓存看到BusRd,检查是否有有效副本,通知发送方缓存; 3. 如果其他缓存具有有效副本,则状态转换为共享Shared(S); 4. 如果其他缓存没有有效副本(必须确保所有其他缓存都已报告) ,则状态转换为共享Exclusive(E); 5. 若其他缓存具有副本,则其中一个缓存发送值,否则从主存获取; |
PrWr | 1. 在总线上发出BusRdX信号; 2. 在请求程序缓存中状态转换为Modified(M); 3. 如果其他缓存有副本,则发送值,否则从主存获取; 4. 如果其他缓存有副本,它们将看到BusRdX信号并使其副本无效; 5. 写入缓存块修改该值; | |
Exclusive(E) | 1. 没有生成总线事务; 2. 状态保持不变; 3. 对块的读取命中缓存; | |
2. 状态从Exclusive(E)转换到Modified(M); 3. 对块的写入命中缓存; | ||
Shared(S) | ||
1. 在总线上发出BusUpgr信号; 2. 状态从Shared(S)转换到Modified(M); 3. 其他缓存看到BusUpgr并将其块副本标记为Invalid(I)。 | ||
Modified(M) | ||
Table 1.2 状态转换和对各种总线操作的响应
总线操作 | 备注 | ||
---|---|---|---|
BusRd | 没有状态变化。信号被忽略; | ||
BusRdX | |||
BusUpgr | |||
1. 状态由Exclusive转换为Shared(因为它意味着在其他缓存中进行读取); 2. 将FlushOpt信号和块内容一起发送到总线; | |||
1. 状态由Exclusive转换为Invalid; 2. 将FlushOpt信号和现在已无效的块中的数据一起发送到总线; | 1. 发生PrWr的处理器缓存将接收已失效的块中的数据,因为缓存到缓存的传输通常比内存到缓存的传输延迟要短; 2. 有且仅有一个缓存状态是Exclusive,其他缓存状态是Invalid(I),因此不会有BusUpgr操作; | ||
1. 无状态变化(其他缓存在此块上执行读取,因此仍然共享); 2. 可以将FlushOpt和块的内容一起放在总线上(设计选择,哪个共享状态的缓存可以执行此操作); | |||
1. 状态从Shared转换为Invalid,已发送BusUpgr的缓存状态变为Modified(M); | 对其他Shared(S)状态的缓存的PrWr操作,向总线发出了BusUpgr信号; | ||
1. 状态从Shared转换为Invalid,已发送BusR的缓存状态变为Modified(M); | 英文原文中没有这一项,但是我感觉这种情况是存在的:因为Shared和Invalid状态的缓存是可以共存的,此时对Invalid缓存的PrWr操作会使所有其他Shared缓存副本无效; | ||
1. 状态由Modified转换为Shared; 2.把FlushOpt和数据放在总线上。由BusRd的发送方和内存控制器接收,写入主内存; | |||
1. 状态从Modified转换为Invalid; 2.把FlushOpt和数据放在总线上。由BusRdX的发送方和内存控制器接收,写入主内存; |
仅当缓存行处于Modified或Exclusive状态时,才能自由执行写操作。如果缓存处于Shared(S)状态,则必须首先使所有其他缓存副本无效。这通常通过一种称为“请求所有权”(RFO,Request For Ownership)的广播操作来完成。
保存处于Modified状态的行的缓存 必须侦测(截获)对对应主存位置的所有尝试读取(来自系统中的所有其他缓存),并插入其保存的数据。
- 这可以通过强制读取退出(即稍后重试),然后将数据写入主内存并将缓存行更改为Shared状态来实现。
-
也可以通过将数据从Modified的缓存发送到执行读取的缓存来完成。
注意,只有读未命中时才需要监听(协议确保,如果任何其他缓存可以执行读命中,则Modified缓存将不存在)。
保存处于Shared状态的行的缓存必须侦听来自其他缓存的invalidate或request-for-ownership广播,并在匹配时丢弃该行(通过将其移动到无效状态)。
Modified状态和Exclusive状态总是精确的:即,它们与系统中真正的缓存行所有权情况相匹配。
Shared状态可能不精确:如果另一个缓存丢弃共享行,此缓存可能成为该缓存行的唯一所有者,但不会提升为独占状态。其他缓存在丢弃缓存行时不会广播通知,并且此缓存在不保留共享副本数的情况下无法使用此类通知。
从这个意义上说,Exclusive状态是一种机会主义优化:如果CPU想要修改处于Shared状态的缓存行,则需要一个总线事务来使所有其他缓存副本无效。状态Exclusive允许在没有总线事务的情况下修改缓存行。
3.3 存储缓冲区和无效队列
MESI协议在其简单、直接的实现中表现出两个特殊的性能问题。
- 首先,当写入无效缓存行时,从另一个CPU获取缓存行时会有很长的延迟。
-
其次,将缓存行移动到Invalid状态非常耗时。
为了减轻这些延迟,CPU实现存储缓冲区和无效队列。
3.4 存储缓冲区
写入无效缓存行时使用存储缓冲区。由于写操作仍将继续,CPU会发出一条读无效消息(因此有问题的缓存行和存储该内存地址的所有其他CPU缓存行都将失效),然后将写操作推入存储缓冲区,在缓存行最终到达缓存时执行。
存储缓冲区存在的直接后果是,当CPU提交写操作时,该写操作不会立即写入缓存。因此,每当CPU需要读取缓存行时,它首先必须扫描自己的存储缓冲区,以确定是否存在相同的缓存行,因为有可能相同的缓存行以前由相同的CPU写入,但尚未写入缓存(前面的写入仍在存储缓冲区中等待)。请注意,虽然CPU可以读取其存储缓冲区中自己以前的写操作,但在将这些写操作从存储缓冲区刷新到缓存之前,其他CPU无法看到这些写操作-CPU无法扫描其他CPU的存储缓冲区。
3.5 无效队列
关于无效消息,CPU实现了无效队列,通过该队列,刚收到的无效请求会立即得到确认,但实际上不会被执行。相反,无效消息只需进入一个无效队列,并尽快(但不一定立即)进行处理。因此,CPU可以忽略其缓存中的缓存线实际上无效的事实,因为无效队列包含已接收但尚未应用的无效。请注意,与存储缓冲区不同,CPU不能扫描无效队列,因为CPU和无效队列在物理上位于缓存的两侧。
因此,需要内存屏障。
- 存储(写)屏障将刷新存储缓冲区,确保所有写操作都已应用于该CPU的缓存。
- 读屏障将刷新无效队列,从而确保其他CPU的所有写操作对刷新CPU可见。
此外,内存管理单元不扫描存储缓冲区,导致类似问题。即使在单线程处理器中也可以看到这种效果。
Request-For-Ownership 广播
所有权读取(RFO)是缓存一致性协议中的一种操作,它结合了读取和无效广播。
该操作是由试图写入处于Shared或Invalid状态的缓存行的处理器发出的。该操作会导致所有其他缓存将该行的状态设置为Invalid。
所有权读取事务是一种旨在写入该内存地址的读取操作。因此,此操作是独占的。
它将数据带到缓存中,并使保存此内存行的所有其他处理器缓存失效。在上表中称为“BusRdX”。
4、示例与问题:
4.1 引入store buffer后出现的问题1
试考虑如下代码,a和b的初始值为0
a = 1;
b = a + 1;
assert(b == 2);
大致过程如下图所示:
- a,b假设在内存中的同一个Cache Block中;
- CPU 0 开始执行代码
a = 1
- CPU 0 未命中 a 的缓存,处理器发出 PrWr 信号,同时将 BusRdX 信号放到总线上,使保存此缓存行的所有其他处理器的缓存失效
- CPU 0 将写入操作保存在了存储缓冲区
- 因为其他处理器没有 a 的缓存行的副本,所以从主存中获取
- CPU 0 现在开始执行
了b = a + 1
- CPU 0 从它的缓存中读取了 a ,值为 0,并用于计算。(此时,主存传输给Cache 0 的数据才刚刚到,还未来得及将存储缓冲区的写操作作用到缓存)
- CPU 0 根据缓存中获取到的 a = 0,计算出了 b 的值为 1
- CPU 0 根据存储缓冲区中的写操作,将 Cache 0 中的缓存行上 a 的值更新为 1
- CPU 0 用 b = 1 修改了缓存块
- CPU 0 执行
,结果为 falseassert(b == 2)
出现问题的原因是我们有两份”a”的拷贝,一份在cache-line中,一份在store buffer中。硬件设计师的解决办法是“store forwarding”,当执行PrRr操作时,会同时从cache和store buffer里读取。也就是说,当进行一次PrRr操作,如果store-buffer里有该数据,则CPU会从store-buffer里直接取出数 据,而不经过cache。因为“store forwarding”是硬件实现,我们并不需要太关心。
4.2 引入store buffer后出现的问题2
假如我们让CPU 0 执行:
void flag(void) {
a = 1;
b = 1;
}
让 CPU 1 执行:
void judge(void) {
while (b = 0) continue;
assert(a == 1);
}
-
。缓存行不在CPU0的缓存中,CPU0处理器进行PrWr操作,并将“a”的新值放到存储缓冲区,与此同时发送一个“BusRdX”消息,使保存此内存行的所有其他处理器缓存失效,个人感觉在没有引入Invalidation Queue的情况下,这里应该是阻塞等待响应的,但是因为CPU 1 此时没有缓存副本,所以不会有任何实际的操作,直接返回ACK即可,CPU 0 最终从内存中获取包含“a”的值的缓存行;a = 1
- CPU 1 执行
,但是包含“b”的缓存行不在缓存中,它发送一个PrRr消息,并将BusRd放到总线上;while (b == 0) continue
-
,它已经在缓存行中有“b”的值了(换句话说,缓存行已经处于“modified”或者“exclusive”状态),因此它存储新的“b”值在它的缓存行中;b = 1
- CPU 0 接收到BusRd消息,并且发送FlushOpt带着缓存行中的最新的“b”的值到总线上,同时将缓存行设置为“shared”状态;
- CPU 1 接收到包含“b”值的缓存行,并将其值写到它的缓存行中,内存控制器也接收到“b”值的缓存行,更新内存块;
- CPU 1 现在结束执行
,因为它发现“b”的值是1,它开始处理下一条语句。while (b == 0) continue
-
,并且,由于CPU 1 工作在旧的“a”的值,因此验证失败。assert(a == 1)
- CPU 0 把存储缓冲区的“a”的值写入到它的缓存 Cache 0 中,个人感觉这个地方可以再次修改缓存行为“Modified”状态,并再次发送“BusRdX”消息到总线,使保存此内存行的所有其他处理器缓存失效,(但是这里我没查阅资料,如果讲得不对,欢迎指正)
总而言之,可能出现这类情况,b已经赋值了,但是a还没有,所以出现了
b = 1, a = 0
的情况。对于这类问题,硬件设计者也爱莫能助,因为CPU无法知道变量之间的关联关系。所以硬件设计者提供了
memory barrier
指令,让软件来告诉CPU这类关系
解决办法是:使用硬件设计者提供的“内存屏障”来修改代码:
void foo(void)
{
a = 1;
smp_mb();// 强制刷新store-buffer,再继续进行后面的赋值
b = 1;
}
4.3 Invalidate Queue的引入
store buffer一般很小,所以CPU执行几个store操作就会填满, 这时候CPU必须等待invalidation ACK消息(得到invalidation ACK消息后会将storebuffer中的数据存储到cache中,然后将其从store buffer中移除),来释放store buffer缓冲区空间。
“Invalidation ACK”消息需要如此长的时间,其原因之一是它们必须确保相应的缓存行实际变成无效了。如果缓存比较忙的话,这个使无效操作可能被延迟。例如,如果CPU密集的装载或者存储数据,并且这些数据都在缓存中。另外,如果在一个较短的时间内,大量的“使无效”消息到达,一个特定的CPU会忙于处理它们。这会使得其他CPU陷于停顿。但是,在发送应答前,CPU 不必真正的使无效缓存行。它可以将使无效消息排队。并且它明白,在发送更多的关于该缓存行的消息前,需要处理这个消息。
一个带Invalidation Queue的CPU可以迅速应答一个Invalidation Ack消息,而不必等待相应的行真正变成无效状态。于是乎出现了下面的组织架构:
这样可以提高效率,但是仍然无法避免
4.2 引入store buffer后出现的问题2
中存在的问题。
- CPU0执行
。因为cache-line是shared状态,所以新值放到store-buffer里,并传递invalidate消息来通知CPU1a=1
- CPU1执行 while(b==0) continue;但是b不再CPU1-cache中,所以发送read消息
- CPU1接受到CPU0的invalidate消息,将其排队,然后返回ACK消息
- CPU0接收到来自CPU1的ACK消息,然后执行smp_mb(),将a从store-buffer移到cache-line中。(内存屏蔽在此处生效了)
- CPU0执行b=1;因为已经包含了该cache-line,所以将b的新值写入cache-line
- CPU0接收到了read消息,于是传递包含b新值的cache-line给CPU1,并标记为shared状态
- CPU1接收到包含b的cache-line
- CPU1继续执行while(b==0) continue;因为为假所以进行下一个语句
- CPU1执行assert(a==1),因为a的旧值依然在CPU1-cache中,断言失败
尽管断言失败了,但是CPU1还是处理了队列中的invalidate消息,并真的invalidate了包含a的cache-line,但是为时已晚
出现问题的原因是,当CPU排队某个invalidate消息后,并做错了应答Invalidate Ack, 但是在它还没有处理这个消息之前,就再次读取了位于cache中的数据,该数据此时本应该已经失效,但由于未处理invalidate消息导致使用错误。
void bar(void)
{
while (b == 0) continue;
smp_mb();
assert(a == 1);
}
void foo(void)
{
a = 1;
smp_wmb();/*CPU1要使用该值,因此需要及时更新处理store buffer中的数据*/
b = 1;
}
void bar(void)
{
while (b == 0) continue;
smp_rmb();/*由于CPU0修改了a值,使用此值时及时处理Invalidation Queue中的消息*/
assert(a == 1);
}