内存一致性 Memory Coherency
还记得片内总线吗^,因为所有的 CPU Cores 和内存控制器都连接在这个总线上,所以我们使用总线来实现一致性。
场景一(总线嗅探):一个 CPU 修改了主存的共享变量,其它 CPU 不知情
通知机制。当一个 CPU 修改了主存的数据时,其它 CPU 都会收到相应的数据变更通知,收到通知的 CPU 如果发现自己也缓存了对应的数据,那么就会将自己缓存的数据所在缓存行标记为失效。总线嗅探是通过 CPU 侦听总线上发生的数据交换操作,当总线上发生了数据操作,那么总线就会广播对应的通知。
场景二(总线仲裁):一个 CPU 修改了主存的共享变量,其它 CPU 不知情
我们需要先明白 Consistency 和 Coherence 的区别(虽然两者在中文中都叫做一致性):
- Consistency:通常跟在 memory 后面。多线程同时进行 load/store 操作时,怎样的执行顺序是对的,怎样是错的。
- Coherency:Coherency 这个词通常跟在 cache 后面,即缓存一致性 (cache coherence)。解决缓存一致性问题的方法也被称为缓存一致性协议 (cache coherence protocol)。
A Primer on Memory Consistency & Cache Coherence 笔记1 – JciX ~
首先有两个概念:memory order 和 program order。
Memory order 是指总的程序执行时的 memory 操作顺序,program order 是指单个核心上指令执行的逻辑上顺序。
根据定义,A memory system is coherent if:
The results of a parallel program’s execution are such that for each memory location, there is a hypothetical serial order of all program operations (executed by all processors) to the location that is consistent with the results of execution, and:
- Memory operations issued by any one processor occur in the order issued by the processor
- The value returned by a read is the value written by the last write to the location… as given by the serial order
Memory coherency 的问题只有在 Multi-processor 的系统中才会存在,在 single core 的系统上是不存在这个问题的。
Consistency / Memory Consistency / Memory Consistency Model / Memory Model
这些都是同一个东西。
A Primer on Memory Consistency & Cache Coherence 笔记1 – JciX ~
Sequentially Consistent, SC
是最简单的内存一致性。
在 SC 中,memory order 遵循每个核心的 program order。
Memory coherency & Cache coherency
Difference between Cache Coherence and Memory Consistency - GeeksforGeeks
MESI / 总线通知 / 总线嗅探
MESI - 飞书云文档
CHA (Caching/Home Agent)
四个功能:
- As a caching agent, it is responsible for receiving requests to the local LLC slice from other cores or sockets.
- As a homing agent, it is responsible for issuing requests to remote cores for memory accesses that missed in the private caches and the local LLC slice.
- Further, the CHA is responsible for maintaining cache coherence between the various cores.
- In multisocket systems, the CHA will also interact with Intel’s Ultra-Path Interconnect (UPI) to send packets.
内存可见性
多线程环境下,一个线程修改共享变量后,其他线程能否立即看到该修改。
Memory barrier (membar, memory fence, 内存屏障) / LFENCE / SFENCE
Memory reordering 是指指令实际执行的顺序与代码编写时的顺序不一致,即指令发生重排,通常在以下两个步骤发生:
- Compiler reordering (compile time):对应 compiler barrier;
- CPU reordering (run time):对应 CPU barrier。
Memory reordering 的原则是不影响单线程的正确性。但是在多线程情况下可能会有问题。Memory barrier 就是来解决这个问题的。
我们日常不使用 memory barrier 也没有多线程问题的原因是,我们使用了 spinlock/mutex 等锁机制内部已经使用到 memory barrier,只有在无锁编程中才需要开发者显式地使用 memory barrier。
int a = 1;
// thread 1
void foo(void) {
while (a) ;
}
// thread 2
void bar(void) {
a = 0;
}
上述代码如果开启了编译优化,foo() 函数会陷入无限循环,这是因为优化之后 foo() 直接在寄存器缓存了 a,不去内存读了。这在单线程环境无伤大雅,但是多线程环境就存在陷入死循环的问题。根本原因是编译器不知道上层软件到底设计为 single-thread 还是 multi-thread,因而解决办法是由软件编写方(通过 compiler barrier)显式地告诉编译器。
barrier() 就是其中一种常用的 compiler barrier,其具体实现是编译器相关的,gcc 版本的实现在 include/linux/compiler-gcc.h 中定义:
#define barrier()__asm__ __volatile__("": : :"memory")
实际上是一个空指令,其中只是用到了一个 "memory" clobber。"memory" clobber 实际上是告诉编译器,这条指令可能会读取或写入任何内存地址,那么编译器会变得保守起来,使得该条指令之前的内存访问操作不会移到这条指令后面,该条指令之后的内存访问操作也不会移到这条指令前面,从而防止编译时编译器优化造成的指令顺序重排,从而确保 barrier() 前后的代码块的相对顺序。此外 "memory" clobber 还有一个副作用是,编译器会将所有寄存器中缓存的值下刷到内存,之后再重新读取内存中的值并缓存到寄存器中,从而达到抑制 compiler reordering 优化的目的。
volatile 关键词用于告知编译器,其修饰的变量的值很可能被程序之外的因素(如该变量存储于硬件寄存器 IO 映射的内存)改变,因而防止编译器对该变量进行缓存优化;对于 volatile 修饰的变量,编译器不能对该变量进行缓存,当每次使用该变量的值时,编译器必须从内存重新读取该变量的值。虽然 barrier() 和 volatile 都有抑制编译器优化的效果,但是两者还是存在着细微的差别:由于 volatile 是修饰一个变量的,那么 volatile 就会一直伴随着这个变量,也就是说这个变量再也不能使用寄存器对其进行缓存,今后访问这个变量时每次都需要从内存重新读取该变量的值。
volatile 会完全限制编译器的编译优化,因而实际上 Linux 内核中越来越不推荐使用 volatile。
虽然 barrier() 和 volatile 都有抑制编译器优化的效果,但是两者还是存在着细微的差别
在理解内存屏障之前,请先学习 MESI^,因为内存一致性是 MESI 和内存屏障共同来保证的。
在单处理器的情况下,不需要任何额外的操作便能保持正确的顺序。 但是在多处理器下由于每一个处理器都有自己的缓存,这样就可能出现一个 CPU 上的缓存数据与另一个上的缓存数据不一致的问题。
几乎所有的处理器都至少支持一个粗粒度的屏障指令(通常称为 Fence,也叫全屏障),这通常都是最耗时的指令之一(它的开销通常接近甚至超过原子操作指令)。
内存屏障的作用:阻止屏障两边的指令重排序,在内存屏障之前的指令和之后的指令不会由于系统优化等原因而导致乱序。
语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
-
lfence:load fence(读内存屏障,it combines LoadLoad and LoadStore barriers)。对于 Load Barrier 来说,在指令前插入 Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;保证不会将读屏障之后的代码排在读屏障之前(因此在读屏障执行结束时,后面的 load 的代码不会重排到读屏障之前(所以挡得是后面),从而读到了 cache 里 stale 的数据,因为此时我们还没有 invalid cache)。 -
sfence:store fence(写内存屏障,it is a StoreStore barrier)。对于 Store Barrier 来说,在指令后插入 Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见;保证不会将写屏障之前的代码排在写屏障之后(所以挡得是前面)(因此在写屏障执行结束时,前面的代码可以保证已经完全执行完毕了,从而不会在此屏障结束时才开始写,那我们的屏障刷 cache 到 memory 就没有意义了)。 -
mfence:memory fence(全屏障,读写屏障),前面两者的性质都有。MFENCE = SFENCE + LFENCE。
所以说,lfence/sfence 应该是做了两件事?
- 保证指令顺序不重排,同时
- 进行刷 cache / invalid cache 的操作。
扩展阅读:(8 封私信 / 80 条消息) intel x86系列CPU既然是strong order的,不会出现loadload乱序,为什么还需要lfence指令? - 知乎
金阶之路:内存屏障(Memory Barriers) - 知乎
Memory barrier & Lfence Sfence - 知乎
https://www.kernel.org/doc/Documentation/memory-barriers.txt
内存一致性模型
内存一致性模型本质就是多核 CPU(以及外设) 对 “内存读写应该以什么顺序生效、对其他核可见” 的规则契约。影响内存一致性的三要素:
- 乱序执行;
- 写缓冲区(比如 WCB);
- 多级缓存(L1/L2/L3)。
一个不算太长的简介入门:内存模型和内存序 - 杰哥的{运维,编程,调板子}小笔记 重点查看这一章节。
SC (Sequential Consistency) 强内存序模型
最强内存模型:
- 每个核内部:严格遵守程序序:代码怎么写,硬件就必须怎么执行,任何重排都不允许。
- 所有核之间:共享一个全局总序:不存在 “你看到的顺序和我看到的不一样”。
第一点很好理解,第二点拆开就是:不管系统怎么运行,得到的结果就好像把所有节点的所有操作按照某个 sequential order 排序后运行,且在这个顺序中,来自同一个节点的操作仍然保持着它们在节点中被指定的顺序。可以看 (7 封私信 / 13 条消息) 深入浅析一致性模型之Sequential Consistency - 知乎 这个里面 Sequential Consistency 的例子很直观。
如果在硬件层满足 Sequential Consistency,肯定会大大降低效率,所以一般这些工作就会交给上层的软件开发人员来做。软件做难道不是效率更低吗,为什么不让硬件直接强保证这种内存序?
- 硬件强保证 SC:把所有 CPU 核心 “绑死”,让 99% 不需要强序的代码,为 1% 需要同步的代码买单。
- 软件显式控制 = 只在需要序的地方加序,不浪费整体性能。
TSO (Total Store Order) 中等内存序模型
这也是 x86 目前采用的内存序模型。