A09 —— 内存屏障和缓存一致性:多核访问的乱序问题

金句:在多核 CPU 里,你看到的内存顺序,不一定是真正的顺序。


多核 CPU 的内存访问乱序

现代 CPU 会为了性能优化而重新排序内存访问指令。但在多核程序中,这种乱序可能导致严重的问题。

Bash
内存访问乱序的例子:

线程 A:
  data = 42;        // Store A
  flag = true;      // Store B

线程 B:
  if (flag) {       // Load B
    print(data);    // Load A
  }

期望:flag=true 时,data 应该是 42
但 CPU 乱序后可能:flag=true 时,data 还是旧值!

原因:CPU 为了减少 stall,会重排指令

Bash
CPU 的内存重排类型(x86/x64):

1. 编译器重排:
   - 编译器优化代码顺序
   - 取决于编译器和优化级别

2. CPU 运行时重排:
   - Store Buffer(写缓冲)
   - Invalidate Queue(失效队列)
   - 流水线并行

x86 的内存模型(Total Store Order):
   - 所有核看到 Store 的顺序相同(TSO)
   - 但 Store 可能被缓冲,导致其他核看到"先读后写"
   - Store Buffer 的存在导致弱保证

ARM/RISC-V 的内存模型(Relaxed):
   - 更多的重排可能
   - 更容易出现内存顺序问题

内存屏障(Memory Barrier)

内存屏障(Memory Fence/Memory Barrier)是告诉 CPU “在此之前的所有内存访问必须在此之前完成”的指令。

Bash
内存屏障的作用:

1. 写屏障(Store Barrier / SFence):
   - 确保所有之前的写操作对其他核可见
   - 刷新 Store Buffer 到 L1 Cache

2. 读屏障(Load Barrier / LFence):
   - 确保所有之前的读操作从全局内存读取
   - 清空 Invalidate Queue

3. 全屏障(Full Barrier):
   - SFence + LFence
   - 所有内存操作必须完成

代码示例(无屏障):
  flag = true;        // Store
  data = 42;          // Store
  // 可能:data=42 在 flag=true 之前对其他核可见

代码示例(有屏障):
  data = 42;          // Store
  asm volatile ("sfence" ::: "memory");  // 写屏障
  flag = true;        // Store
  // flag=true 时,data=42 一定对其他核可见

Bash
Linux 内核的内存屏障接口:

#include <linux/barrier.h>

mb();        // 全屏障(读写)
smp_mb();    // 多核全屏障(SMP)
smp_wmb();   // 多核写屏障
smp_rmb();   // 多核读屏障
dma_rmb();   // DMA 读屏障
dma_wmb();   // DMA 写屏障

用户态 C11/C++11  #include <stdatomic.h>
  atomic_thread_fence(memory_order_seq_cst);  // 全屏障
  atomic_thread_fence(memory_order_release);  // 写屏障
  atomic_thread_fence(memory_order_acquire);  // 读屏障

缓存一致性(MESI 协议)

多核 CPU 的每个核有自己的 L1/L2 Cache,而主存是共享的。MESI 协议确保每个核看到一致的内存数据。

Bash
MESI 协议的状态:

M(Modified):脏行,本核独占修改,其他核无效
E(Exclusive):干净行,本核独占,无修改
S(Shared):干净行,多核共享,内容相同
I(Invalid):无效行,本核没有这行数据

状态转换:

1. 读 Modified 行(另一个核请求):
   - 本核先写回主存
   - 状态 → Shared

2. 写 Shared 行(另一个核也有):
   - 先发 Invalidate 给其他核
   - 其他核置 Invalid
   - 本核状态 → Modified

3. 写 Exclusive 行:
   - 直接修改,状态 → Modified
   - 不需要通知其他核(只有本核有)

MESI 的开销:
  - Invalidate 消息需要总线带宽
  - 写竞争严重时,性能下降
  - 需要内存屏障来保证顺序

Bash
Store Buffer 和 Invalidate Queue:

Store Buffer(写缓冲):
  - 本核的写先放入 Store Buffer
  - 不立即写 L1/L2 Cache
  - 其他核可能暂时看不到
  - 解决:写屏障刷新 Store Buffer

Invalidate Queue(失效队列):
  - 其他核的 Invalidate 消息先放入队列
  - 不立即处理
  - 导致读到的可能是旧数据
  - 解决:读屏障清空 Invalidate Queue

这就是为什么内存屏障在多核编程中如此重要:
  - 屏障保证 Store Buffer 刷新
  - 屏障保证 Invalidate Queue 处理完毕

volatile 的作用和局限

Bash
volatile 的作用:

volatile int flag;
flag = 1;  // 不会被编译器优化掉

volatile 的局限:
  - 防止编译器优化(读到缓存而不是内存)
  - 不保证 CPU 的运行时重排
  - 不保证多核之间的可见性
  - 不保证原子性

Linux 内核的做法:

  volatile int flag;  // 单核有用,多核不够

  // 多核需要:
  { smp_mb(); }  // 内存屏障

或者用原子操作:

  #include <linux/atomic.h>
  atomic_t flag;
  atomic_set(&flag, 1);  // 原子写,有屏障语义

Bash
C11/C++11 原子操作替代 volatile:

#include <stdatomic.h>
atomic_int flag = ATOMIC_INIT(0);

// 写(Release):
atomic_store(&flag, 1);  // 有 release 语义
// 或
flag.store(1, std::memory_order_release);

// 读(Acquire):
int x = atomic_load(&flag);  // 有 acquire 语义
// 或
int x = flag.load(std::memory_order_acquire);

memory_order_seq_cst:全屏障(最严格)
memory_order_release:写屏障
memory_order_acquire:读屏障

总结

  • 内存乱序:CPU 为了性能重排内存访问,多核场景下可能导致错误
  • 内存屏障:sfence(写屏障)/ lfence(读屏障)/ mfence(全屏障),确保顺序
  • MESI:Modified/Exclusive/Shared/Invalid 四种状态,保证多核缓存一致性
  • Store Buffer:写先缓冲,需要 sfence 刷新;Invalidate Queue:需要 lfence 清空
  • volatile:只防止编译器优化,不保证 CPU 重排和多核可见性
  • 正确做法:用 C11/C++11 原子操作或 Linux 内存屏障 API

下篇预告(A10):用户程序怎么分析和优化内存使用?malloc_stats、mallinfo、valgrind 的 massif、以及 pmap 的使用。


关注公众号「AI不着急」,回复”资料”获取内存学习路线图。

最后修改: 2024年8月22日

作者

评论

发表评论

您的邮箱地址不会被公开。