浅谈"内存屏障"-硬件角度
老规矩先推荐一篇文章:
0、前言
阅读本系列带着几个问题:
- 了解 volatile 如何保证可见性的?
- CAS 是如何保证对同一个地址操作的原子性?
- volatile 如何避免指令重排序的?
- MESI 协议与内存屏障有何种关联?
从不同角度来理解内存屏障,从不同角度来理解内存屏障,谈论“内存屏障”共有三篇文章,分别从开发角度、语言角度和硬件角度。
本文主要是从硬件角度了解内存屏障(memory barrier)。
1、cache-coherence protocol(缓存一致性协议)
1.1 现代计算机系统缓存结构
《Memory Barriers: a Hardware View for Software Hackers》 这篇文章主要从硬件角度了解硬件设计者的需求,以及 read/write memory barrier 如何运作。本文的图示都是从这篇文章截取出來的。
上图是现代计算机接口简化示例,CPU 执行速度很快,CPU 读写 main memory(主存)很慢,所以要加上 cache 减少读写主存的次数,从 Latency Numbers Every Programmer Should Know 得知,读写主存的时间是 100 ns,L1 cache 是 0.5 ns,两者相差 200 倍。
由于各个 CPU 的各自 cache 的出现,每个 CPU 都是对自己 cache 里面的数据进行读取,这就会导致 共享变量
在多线程的情况下可能出现数据不一致的问题,所以需要确保各个 CPU 有看到「一致的数据」(注意:目前重点放在 CPU 之间,而不是 cache 和 memory 保持同步,这点细微的差异会影响到架构设计)。保证这个规则的统称为 cache-coherence protocol(缓存一致性协议)
。其中, MESI protocol
是一个基于 Invalidate 的缓存一致性协议,从 MESI protocol 可以了解到 CPU 之间是如何维持数据的一致性的。
1.2 MESI
本文开头那篇文章也有简要介绍下 MESI 协议,更详细更准确的可以参考 Wikipedia 的介绍。
1.2.1 简要介绍
首先非常简单的说一下 MESI 协议,该协议规定每个cache line 有 4 种不同的状态:
- Modified(M):表示这个 cache line 已经被修改,但是还未写回主存( cache 中数据与主存中数据不一致!所有其它 cpu 对这个 cache line 的读操作必须在该 cache line 写回主存后, 写回主存后,状态变换到 share。
- Exclusive(E):表示这个cache line 目前是独有的且与主存一致,可以转换到 shared 或者 M,重点是可以转换到 M,也就意味着可以对其修改!
- Shared(S):表示这个 cache line 在其它 cpu 的 cache 中也存在, 目前也与主存一致, 随时会被invalidate。
- Invalid(I):表示这个 cache line 目前不可用。
这个图的含义就是当一个 cpu 持有一个 cache line 的状态为 Y 时,其它 cpu 对应的 cache line应该处于状态 X,比如地址 0x00010000 对应的 cache line 在 cpu0 上为状态 M,则其它所有的 cpu 对应于 0x00010000 的 cache line 都必须为 I,0x00010000 对应的 cache line 在 cpu0 上为状态 S,则其它所有的 cpu 对应于 0x00010000 的 cache line 可以是 S 或者 I^参考3^。
1.2.2 多核竞争^参考:[3]^
当两个 core 同时执行针对同一地址的 "修改操作" 时,其实他们是在试图修改每个 core 自己持有的 cache line,假设两个 core 都持有相同地址对应 cache line,且各自 cache line 状态为 S,这时如果要想成功修改,就首先需要把 S 转为 E 或者 M,则需要向其它 core invalidate 这个地址的 cache line,则两个 core 都会向 ring bus 发出 invalidate 这个操作,那么在 ring bus上就会根据特定的设计协议仲裁是 core0,还是 core1 能赢得这个invalidate,胜者完成操作, 失败者需要接受结果,并 invalidate 自己对应的 cache line,再读取胜者修改后的值, 回到起点。
到这里对于我们的 "修改操作" 来说,其实锁并没有消失,只是转嫁到了ring bus 的总线仲裁协议
中。而且大量的多核同时针对一个地址的 修改操作 会引起反复的互相 invalidate 同一cache line, 造成 pingpong 效应, 同样会降低性能。
最后, 更进一步 ring bus 的协议又是什么? x86这方面的公开信息非常罕见。唯一可以推断的是这个协议会保证公平性,在 invalidate 的竞争中不会总是一个 core 赢, 从而保证不会有 starving core. 在 power4/5 的设计中有一篇论文涉及到 power 具体细节, 大家可以参考:LLC ring bus design
1.2.3 存在的问题
通过 MESI 协议
了解到,任何一个 CPU 要写入资料前,都要先确保其他 CPU 已 invalidate 同一个位置的 cache 后(想要写入的 CPU 广播 invalidate 信号,其他CPU 响应 invalidate ack),才能写数据到自己的 cache,并在稍后
回写到主存。
这个设计确保数据的一致性,不用担心同一时间同一个位置的数据会有不同的值,但是代价是写入 cache 的速度会有点慢,让 CPU 闲置。
假设 CPU 0 打算写入数据到位置 X,CPU 1 的 cache 有 X 的值。慢的原因如下:
- CPU 0 要等 CPU 1 回 invalidate ack。
- CPU 1 的 cache 可能太忙而拖慢了回复时间 (比方同时从 cache 大量的读写数据,或是短时间收到大量 invalidate ack)。
2、Store Buffer 和 Invalidate Queue
一个重要点:MESI 协议要求在缓存不命中(miss)且数据块在另一个缓存行时,允许缓存行到缓存行的数据复制。
针对上述存在的问题,引入 store buffer 和 invalidate queue,通过以下的方法可以减少 CPU 0 的闲置时间:
- CPU 0 不等 invalidate ack:先写入 store buffer,然后继续做其他事。之后收到 invalidate ack 再更新 cache 的状态。因为最新的数据可能存在 store buffer,CPU 读数据的顺序变成 store buffer -> cache -> main memory。
- CPU 1 立即回 invalidate ack:收到 invalidate 信号时,记录到 invalidate queue 里,先回 invalidate ack,稍后处理队列中的 invalidate 信号。
因为 store buffer 很小,在 store buffer 满的时候,CPU 0还是需要等待 invalidate ack,所以加上 invalidate queue,双管齐下减少 CPU 0 等待时间,提高 CPU 利用率。
扩展后的 CPU 架构如下:
因为多了 store buffer 和 invalidate queue,cache 之间的数据就没有达到缓存一致性的要求,本文开头介绍的文章有更详细的说明会产生问题的情况,建议读一读。
2.1 store buffer 的问题与 write memory barrier
int a = b = 0;
void foo(void)
{
a = 1;
b = 1;
}
void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}
考虑 CPU 0 执行 foo(),CPU 1 执行 bar() ,假设 cache 的状态如下:
a b
------------------------
CPU 0: Shared Modified
CPU 1: Shared Invalid
CPU 0 步骤如下:
- 写入 a 的值(为 1)到 store buffer,但 cache 里仍是 0。
- 广播 “invalidate a”。
- 修改 b 的值,因为 b 的状态是 Modified,cache 的值更新成 1。
接着 CPU 1 从 CPU 0 的 cache 读到 b = 1,继续执行 assert(),从自己的 cache 里面取值(为 0)。然后才接收到 CPU 0 发送的 “invalidate a“ 的信息,但已经太迟了。
问题出在 store buffer 破坏了 缓存一致性,数据不再是同一时间只有一份值。于是 CPU 需要提供 write memory barrier,让软件有机会避免这个问题。
write memory barrier 确保之前在 store buffer 里的数据会先更新到 cache,然后才能写入 barrier 之后的数据到 cahe。假设我们在 foo()
的 a=1
和 b=1
之间插入一个 write memory barrier。实际情况发生如下:
- write memory barrier 先设 store buffer 里面的数据为 “marked”(即 a=1)。
- 写入 b 的时候,因为发现 store buffer 里有 marked 的栏位,所以即使 b 已经处于 Modified 状态,仍需要写入
b=1
到 store buffer,不过状态是 “unmarked”。 - 待收到 a 的 invalidate ack 后,cache 中 a 的状态改为 Modified,然后先写入有 marked 栏位的值到 cache,再写入 unmarked 栏位的值到 cache。
2.2 invalidate queue 的问题和 read memory barrier
同样的程序为例,假设 CPU 1 的 cache 里的 a 处于 shared。CPU 0 已更新 a、b 到它的 cache,CPU 1 的 invalidate queue 里有“invalidate a”,但还没处理。
这时 CPU 1 依序读 b、a 的值,会从 CPU 0 的 cache 读到 b = 1,然后从自己的 cache 读到 a=0
(因为还没处理 invalidate a 信号)。所以需要 read memory barrier 确保先清空 invalidate queue 再继续读数据。
若在 assert(a == 1)
之前插入 read memory barrier,执行顺序变成这样:
- CPU 1 执行 read memory barrier 时会设 invalidate queue 里的数据为 “marked”。
- CPU 1 读 cache 里 a 的值时,发现 invalidate queue 里有标记 a,于是首先执行 invalidate a 再继续读 a 的值。
- 执行 invalidate a 后,就不会读自己 cache 的值,而是改从 CPU 0 的 cache 读到最新的值,达到「依序读 b、a 的值」的效果。
3、结论
硬件为了减少读写 主存
而出现 cache。有 cache 就要保证 cache 之间的数据一致性(同一个时间同一位置只有一个值)。但确保 cache 数据完全一致容易让 CPU 闲置,于是出现了 store buffer 和 invalidate queue 减少 CPU 闲置。代价是只保证 CPU 自己会让自己写入的最新资料,但其他 CPU 不一定。
为了让其它 CPU 有需要的時候也能读到最新的数据,针对 store buffer 和 invalidate queue 的副作用设计了 write/read memory barrier。于是开发者在需要的时候可以用 memory barrier 确保关键的数据(可能产生数据竞争的数据)有依正确的顺序更新 (没保证更新的时间,这点很关键
)。CPU 在多数情況下仍能避免闲置。
到此可以了解为什么这两种操作
合在一起比较符合 CPU 架构:
- 一个 thread 「先 write X 后执行 write memory barrier」
- 另一个 thread 「先执行 read memory barrier 后 read X」
两者合起来构成 happens-before relation。由此可以理解 Sequential-consistent 和硬件的运作方式差太多,会让 CPU 闲置,从而无法发挥多 CPU 的效能。