MESI和go内存一致性模型

117 阅读10分钟

CPU多级缓存

现代CPU为了缓解CPU速度和内存速度的不匹配,采用了CPU缓存来加速数据的读取,避免直接访问内存。而缓存集成有以下三种方式。

  • 集中式缓存:一个缓存和所有处理器直接相连,多个核共享这一个缓存;
  • 分布式缓存:一个处理器仅和一个缓存相连,一个处理器对应一个缓存;
  • 混合式缓存:在 L3 采用集中式缓存,在 L1 和 L2 采用分布式缓存。

现代的多核处理器多采用缓和式缓存的方式将缓存集成到芯片上,一般情况下,L3是所有处理器核共享的,L1和L2是每个处理器独享的。

image.png

缓存的引入虽然提高了存取数据的速度,但也带来缓存一致性问题。如果有一个CPU修改了内存中的值,就必须有一种机制保证其它CPU能够观察到这个修改。也就是下面所要提到的MESI缓存一致性协议。在缓存一致性问题中,CPU修改自己的缓存策略非常重要,我们先从缓存的写策略说起。

缓存的写策略

当CPU修改了缓存中的数据的时候,这些修改什么时候能传播到内存呢?主要有两种策略:写回和写直达

  • 写回:修改缓存的时候,并不修改内存,而是置标志位,当该缓存块被淘汰的时候再写回内存
  • 写直达:修改缓存的时候同步修改内存

同时,对某个CPU的缓存中执行写操作,修改其中的某个值时,其它CPU的缓存所保有该数据副本的更新策略也有两种:写更新和写无效

  • 写更新:某个CPU更新它的缓存中的值,都必须发起一次总线请求,同时其它的CPU将它们缓存中的值更新,所以写更新会很占用总线带宽,但如果其它CPU之后要多次读取该值,写更新效率就较高
  • 写无效:某个CPU更新它的缓存中的值,会发起一次总线请求,将其它CPU中的缓存设置为无效。这也意味着,其它CPU访问该值时会发现缓存失效,需要从内存中载入新的数据

另一个方面就是当前写入的数据不在缓存中的时候,根据是否要先将数据加载到缓存中。写缓存策略又分为两种:写分配和写不分配

  • 写分配:写入数据的时候将数据读入缓存
  • 写不分配:直接将要写入的数据写到内存,并不将数据读入缓存

MESI缓存一致性协议

缓存一致性指的是:保证同一个数据在每一个CPU的私有缓存中副本是相同的。MESI协议是基于:写回、写无效策略的协议。它将缓存块的状态分为四种:

  • Modified(M):缓存块有效,但是是“脏”的,其数据与主存中的原始数据不同,同时还表示处理器对于该缓存块的唯一所有权,表示数据只在这个处理器的缓存上是有效的;
  • Exclusive(E):缓存块是干净有效且唯一的;
  • Shared(S):缓存块是有效且干净的,有多个处理器持有相同的缓存副本;
  • Invalid(I):缓存块无效。

另外我们分别定义协议中不同的事件。处理器对缓存的请求:

  • PrRd:处理器请求从缓存块中读出; +** PrWr**:处理器请求向缓存块写入。

而总线对缓存的请求和“写直达”的缓存一致性协议稍有不同,分别是: +BusRd:总线侦听到一个来自另一个处理器的读出缓存请求;

  • BusRdX:总线侦听到来自另一个尚未取得该缓存块所有权的处理器读独占(或者写)缓存的请求;
  • BusUpgr:侦听到一个其他处理器要写入本地缓存块上的数据的请求;
  • Flush:总线侦听到一个缓存块被另一个处理器写回到主存的请求;
  • FlushOpt:侦听到一个缓存块被放置在总线以提供给另一个处理器的请求,和 Flush 类似,但只不过是从缓存到缓存的传输请求。

同时,各个状态之间的变化可以用以下的状态机来表示:

image.png

内存屏障

不管你学习的语言是C++还是Java,想必都听过内存屏障这个词语。既然已经有了MESI缓存一致性协议确保缓存的一致性,为什么还要有内存屏障呢?我们回顾一下上一节提到的内存结构:

image.png

从上面这张图你可以看到,Cache 和主内存 (Memory) 是直接相连的。一个 CPU 的所有写操作都会按照真实的执行顺序同步到主存和其他 CPU 的 cache 中。严格遵守 MESI 协议的 CPU 设计,在它的某一个核在写一块缓存时,它需要通知所有的同伴:我要写这块缓存了,如果你们谁有这块缓存的副本,请把它置成 Invalid 状态。Invalid 状态意味着该缓存失效,如果其他 CPU 再访问这一缓存区时,就会从主存中加载正确的值。发起写请求的 CPU 中的缓存状态可能是 Exclusive、Modified 和 Share,每个状态下的处理是不一样的。如果缓存状态是 Exclusive 和 Modified,那么 CPU 一个核修改缓存时不需要通知其他核,这是比较容易的。但是在 Share 状态下,如果一个核想独占缓存进行修改,就需要先给所有 Share 状态的同伴发出 Invalid 消息,等所有同伴确认并回复它“Invalid acknowledgement”以后,它才能把这块缓存的状态更改为 Modified,这是保持多核信息同步的必然要求。这个过程相对于直接在核内缓存里修改缓存内容,非常漫长。这也就会导致,某个核请求独占时间比较长。

为了解决这个问题,每个CPU核心又引入了一个名为Store buffer的结构,它是硬件实现的缓冲区,它的读写速度比缓存的速度更快,所有面向缓存的写操作都会先经过store buffer。结构如下:

image.png

在这样的结构里,如果 CPU 的某个核再要对一个变量进行赋值,它就不必等到所有的同伴都确认完,而是直接把新的值放入 store buffer,然后再由 store buffer 慢慢地去做核间同步,并且将新的值刷入到 cache 中去就好了。而且,每个核的 store buffer 都是私有的,其他核不可见。

我们现在来举个例子。我们使用两个 CPU,分别叫做 CPU0 和 CPU1,其中 CPU0 负责写数据,而 CPU1 负责读数据,我们看看在增加了 store buffer 这个结构以后,它们在进行核间同步时会遇到什么问题。

假如 CPU0 刚刚更新了变量 a 的值,并且将它放到了 store buffer 中,CPU0 自己接着又要读取 a 的值,此时,它会在自己的 store buffer 中读到正确的值。

那如果在这一次修改的 a 值被写入 cache 之前,CPU0 又一次对 a 值进行了修改呢?那也没问题,这次更新就可以直接写入 store buffer。因为 store buffer 是 CPU0 私有的,修改它不涉及任何核间同步和缓存一致性问题,所以效率也得到了比较大的提升。

但用 store buffer 也会有一个问题,那就是它并不能保证变量写入缓存和主存的顺序,你先来看看下面这个代码:

// CPU0
void foo() {
    a = 1;
    b = 1;
}

// CPU1
void bar() {
    while (b == 0) continue;
    assert(a == 1);
}

你可以看到,在这个代码块中,CPU0 执行 foo 函数,CPU1 执行 bar 函数。但在对变量 a 和 b 进行赋值的时候,有两种情况会导致它们的赋值顺序被打乱。

第一种情况是 CPU 的乱序执行。

第二种情况是 store buffer 在写入时,有可能 b 所对应的缓存行会先于 a 所对应的缓存行进入独占状态,也就是说 b 会先写入缓存。

为了解决这个问题,CPU 设计者就引入了内存屏障,屏障的作用是前边的读写操作未完成的情况下,后面的读写操作不能发生。所以说,内存屏障保证了,其他 CPU 能观察到 CPU0 按照我们期望的顺序更新变量总的来说,store buffer 的存在是为提升写性能,放弃了缓存的顺序一致性,我们把这种现象称为弱缓存一致性。在正常的程序中,多个 CPU 一起操作同一个变量的情况是比较少的,所以 store buffer 可以大大提升程序的运行性能。但在需要核间同步的情况下,我们还是需要这种一致性的,这就需要软件工程师自己在合适的地方添加内存屏障了。

伪共享

我们知道,缓存系统中是以缓存行(cache line)为单位存储的。缓存行通常是 64 字节,并且它有效地引用主内存中的一块地址。一个long 类型是8字节,因此在一个缓存行中可以存8个long 类型的变量。所以,如果你访问一个 long 数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个,以致你能非常快地遍历这个数组。

但如果存在这样的场景,有多个线程操作不同的成员变量,但是相同的缓存行,这个时候会发生什么?。没错,伪共享(False Sharing)问题就发生了!有张 Disruptor 项目的经典示例图,如下

image.png

这个时候可以通过填充,使得同一个缓存行的数据分别独占一个缓存行,降低并发的影响。

go语言的发生序关系

Go 的 Goroutine 采取并发的形式运行在多个并行的线程上, 而其内存模型就明确了 对于一个 Goroutine 而言,一个变量被写入后一定能够被读取到的条件。 在 Go 的内存模型中有事件时序的概念,并定义了 happens before ,即表示了在 Go 程序中执行内存操作的一个偏序关系。

Go 中的 happens before 有以下保证:

  1. 初始化:main.init < main.main
  2. Goroutine 创建: go < Goroutine 开始执行
  3. Goroutine 销毁: Goroutine 退出 = ∀ e
  4. channel: 如果 ch 是一个 buffered channel,则 ch<-val < val <- ch
  5. channel: 如果 ch 是一个 buffered channel 则 close(ch) < val <- ch & val == isZero(val)
  6. channel: 如果 ch 是一个 unbuffered channel 则,ch<-val > val <- ch
  7. channel: 如果 ch 是一个容量 len(ch) == C 的 buffered channel,则 从 channel 中收到第 k 个值 < k+C 个值得发送完成
  8. mutex: 如果对于 sync.Mutex/sync.RWMutex 的锁 l 有 n < m, 则第 n 次调用 l.Unlock() < 第 m 次调用 l.Lock() 的返回
  9. mutex: 任何发生在 sync.RWMutex 上的调用 l.RLock, 存在一个 n 使得 l.RLock > 第 n 次调用 l.Unlock,且与之匹配的 l.RUnlock < 第 n+1 次调用 l.Lock
  10. once: f() 在 once.Do(f) 中的调用 < once.Do(f) 的返回