2. 共享内存基础:缓存一致性协议(MESI)、伪共享(False Sharing)问题、内存屏障与可见性

共享内存,说白了就是多个核共用一块物理内存。听起来简单,对吧?但实际跑起来,坑比想象的多得多。

我刚开始做多核开发时,觉得共享内存不就是读写变量嘛,有什么难的。结果第一次调试一个双核数据采集系统,数据老是错乱,查了整整三天。最后发现是缓存一致性问题——两个核各自缓存了同一份数据,互相不知道对方改了。

嗯,从那以后,我再也不敢轻视共享内存了。

2.1 缓存一致性协议(MESI)

先说说为什么需要缓存一致性。现代CPU的L1缓存访问延迟大约1-2纳秒,而主存访问要几十甚至上百纳秒。为了性能,每个核都有自己的缓存。但问题来了:同一个内存地址的数据,可能同时出现在多个核的缓存里。

如果一个核改了它缓存里的数据,其他核怎么办?

MESI协议就是来解决这个问题的。它给每个缓存行(Cache Line,通常是64字节)定义了四种状态:

状态 全称 含义 该缓存行是否与主存一致
M Modified 已修改,仅当前核持有 不一致(脏数据)
E Exclusive 独占,仅当前核持有 一致
S Shared 共享,多个核持有 一致
I Invalid 无效,数据已过时 —

状态切换靠的是总线嗅探(Bus Snooping)。每个核的缓存控制器会监听总线上的读写请求,然后根据请求类型和当前状态做转换。

举个例子:

  • 核A读取变量x,缓存未命中。从主存加载,状态设为E(独占)。
  • 核B也读取x,总线嗅探到核A有这份数据。核A把状态降为S(共享),核B也设为S。
  • 核A修改x。它必须先发一个"我要修改"的信号,让核B把缓存行置为I(无效)。然后核A自己变成M(已修改)。
  • 核B再读x时,发现缓存行是I,只能重新从主存或核A那里获取最新数据。

这个过程,我习惯叫它"缓存行握手"。每次握手都有开销,尤其是跨核通信频繁的时候。

关键点:MESI保证了写传播(一个核的修改最终能被其他核看到)和写串行化(所有核看到同一份数据的修改顺序一致)。但注意,它不保证即时可见性——从修改到其他核看到,中间有延迟。

我的经验:在ARM Cortex-A系列和x86上,MESI的实现细节略有不同。ARM用的是MESI的变种MOESI(多了Owned状态),x86用的是MESIF(多了Forward状态)。做跨平台移植时,别假设行为完全一致。

MESI 状态转换图 M Modified E Exclusive S Shared I Invalid 其他核读请求 其他核读请求 本地写 其他核写请求 本地读(未命中) 其他核读请求 其他核写请求 箭头上的文字表示触发状态转换的事件

2.2 伪共享(False Sharing)问题

伪共享,我愿称之为"多核编程的头号隐形杀手"。它不报错,不崩溃,就是让你的程序跑得比蜗牛还慢。

为什么会这样?

缓存行是64字节。假设你有两个变量:flag_a和flag_b,恰好落在同一个缓存行里。核A频繁修改flag_a,核B频繁修改flag_b。从逻辑上看,它们互不干扰。但从缓存一致性协议的角度看——每次核A改flag_a,核B的缓存行就失效;核B改flag_b,核A的缓存行又失效。两个核来回"踢皮球",性能直接崩掉。

看个代码例子:

// 伪共享的例子
struct Data {
    int flag_a;  // 核A频繁写
    int flag_b;  // 核B频繁写
    // 两个变量很可能在同一个缓存行里
};

// 修复方案:缓存行对齐
struct DataAligned {
    int flag_a;
    char padding[60];  // 填充到64字节
    int flag_b;
};

我在一个实时控制项目里遇到过这个坑。两个核分别处理传感器数据和控制输出,共享一个状态结构体。单核测试一切正常,双核一跑,性能直接腰斩。用perf一看,缓存未命中率飙升到40%以上。加上padding对齐后,性能恢复。

注意:伪共享的检测工具:

  • Linux perf:perf stat -e cache-misses
  • Intel VTune:直接标出伪共享热点
  • Valgrind的cachegrind:模拟缓存行为

我建议在性能调优阶段,把这些工具跑一遍。别等到上线了才发现。

避坑指南:

  • 多核共享的变量,尽量按缓存行对齐(64字节或128字节)。
  • 读多写少的变量和读写频繁的变量,分开放。
  • 如果结构体很小,考虑每个核独立一份副本,最后再合并。

2.3 内存屏障与可见性

MESI保证了缓存一致性,但它不保证可见性。什么意思?

核A写了一个变量,核B可能不会立即看到。因为写操作可能还在写缓冲(Store Buffer)里,或者编译器/CPU重排了指令顺序。

内存屏障(Memory Barrier)就是用来解决这个问题的。它是一条特殊的指令,告诉CPU和编译器:在这条指令之前的内存操作,必须在这条指令之后的内存操作之前完成。

常见的内存屏障类型:

类型 作用 典型场景
读屏障(LFENCE) 保证屏障前的读操作先于屏障后的读操作 读取状态标志
写屏障(SFENCE) 保证屏障前的写操作先于屏障后的写操作 写入数据后设置标志
全屏障(MFENCE) 保证屏障前后的读写操作全部有序 同步点、锁操作

在C11/C++11里,我们可以用原子操作和内存序来隐式插入屏障:

// C11 原子操作示例
#include <stdatomic.h>

atomic_int flag = 0;
int shared_data = 0;

// 线程A
shared_data = 42;
atomic_store(&flag, 1, memory_order_release);  // 写屏障

// 线程B
while (atomic_load(&flag, memory_order_acquire) == 0);  // 读屏障
// 此时保证能看到 shared_data = 42
printf("%d\n", shared_data);

这里memory_order_release和memory_order_acquire就是一对内存屏障。release保证之前的所有写操作在屏障前完成,acquire保证之后的所有读操作在屏障后开始。两者配合,实现了线程间的"happens-before"关系。

核心原则:

  • 写数据的线程用release语义。
  • 读数据的线程用acquire语义。
  • 如果既写又读,用acq_rel或seq_cst。

我个人习惯:能用原子操作+内存序解决的问题,绝不用裸屏障指令。代码可读性高,移植性也好。

常见误区:

  • 以为volatile能保证可见性。volatile只禁止编译器优化,不插入内存屏障,CPU照样可以重排。
  • 以为原子操作不需要屏障。原子操作默认是memory_order_seq_cst(顺序一致性),性能开销大。如果不需要全局顺序,用release/acquire就够了。
  • 在ARM上,内存屏障比x86更昂贵。ARM的内存模型更弱,需要更多的屏障指令。

嗯,到这里,共享内存的三个基础问题就讲完了。MESI是硬件基础,伪共享是常见陷阱,内存屏障是编程工具。三者环环相扣,理解透了,多核编程才算真正入门。