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状态)。做跨平台移植时,别假设行为完全一致。
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是硬件基础,伪共享是常见陷阱,内存屏障是编程工具。三者环环相扣,理解透了,多核编程才算真正入门。