os操作系统——第9讲:缓存一致性协议
目录
第9讲:每个核的心中都有一个“私有缓存”——缓存一致性协议
上一讲我们用内存屏障和自旋锁驯服了指令重排序,多核累加的结果终于正确了。
但当你把任务数量增加到 4 个,性能竟然不升反降:4 个核心同时工作,总吞吐量还不如 2 个核心!
你监控到 CPU 的停滞周期飙升,内存总线繁忙得像早高峰的地铁。为什么?因为每个核心都在“自以为是”地保留私有缓存副本,当它们同时修改相邻的变量时,硬件不得不在核心之间来回“踢皮球”同步缓存行。
这就是多核系统的又一个隐藏杀手——缓存一致性协议,以及它的副作用 伪共享。
1. 缓存:CPU 的“私藏小金库”
CPU 访问内存的速度极慢(几十到几百纳秒),而 CPU 一个时钟周期只需要零点几纳秒。为了掩盖差距,现代 CPU 内置了多层缓存:
- L1 缓存:每个核心私有,32KB 左右,延迟 ~1-2 ns。
- L2 缓存:通常每个核心私有(或两个核心共享),256KB 左右,延迟 ~10 ns。
- L3 缓存:所有核心共享,数 MB 到数十 MB,延迟 ~30 ns。
当 CPU 读取一个变量时,先在 L1 找 → L2 → L3 → 内存。写入时,也要先写到缓存(回写策略),之后才慢慢刷回内存。
问题来了:如果核心 0 修改了自己 L1 缓存中的变量 x,核心 1 的 L1 缓存里还存着旧的 x。核心 1 再读 x 时,就会拿到旧值。
如果 x 是自旋锁变量,后果将是灾难性的。
2. MESI:让私有缓存“统一思想”
硬件工程师早就意识到了这个问题,于是设计了 缓存一致性协议。最流行的是 MESI 协议(及其变种 MOESI、MESIF)。
MESI 为每个缓存行(Cache Line,通常 64 字节)定义了四种状态:
| 状态 | 含义 | 该核心的数据 | 其他核心 |
|---|---|---|---|
| M (Modified) | 该缓存行已被修改,且与内存不一致 | 最新数据 | 其他核心没有该缓存行(或者已无效) |
| E (Exclusive) | 该缓存行与内存一致,且只有本核心拥有 | 与内存相同 | 其他核心没有 |
| S (Shared) | 该缓存行与内存一致,且可能多个核心拥有 | 与内存相同 | 可以有多个 S 状态副本 |
| I (Invalid) | 该缓存行无效 | 无意义 | - |
每个缓存控制器通过总线嗅探(Snooping) 监听其他核心对内存地址的读写请求,并根据当前状态转换。
2.1 状态转换经典场景
假设两个核心(C0, C1),共享变量 x 初始在内存中。
- C0 读 x:C0 缓存 miss,从内存读取,状态变为 E(独占,因为还没人写)。
- C1 读 x:C1 发现总线有 C0 读取的请求,C0 也看到 C1 想读,各自把状态从 E 降为 S(共享)。
- C0 写 x:C0 需要将 C1 中对应的缓存行置为 I(无效)。它先发总线“读失效(RFO)”消息,C1 将自己的状态改为 I。然后 C0 写入数据,状态变为 M(已修改,且独占)。
- C1 再读 x:C1 的缓存行是 I(无效),发出读请求。C0 监听到,将自己缓存行写回内存(或直接通过总线响应),然后 C0 状态从 M 变为 S,C1 也变为 S。
这个协调过程靠总线消息完成,会消耗额外周期。如果两个核心频繁交替修改同一个变量,它们就会反复发送 RFO 和写回,导致缓存行颠簸(Cache Line Bouncing)。性能暴跌。
3. 操作系统无法完全控制 MESI,但可以配合
MESI 协议完全由硬件自动执行,操作系统不需要(也不能)直接干预。但操作系统可以通过内存屏障和原子操作来间接影响协议行为。
当你在多核上执行 atomic_compare_exchange_strong 时,编译器/CPU 通常会生成带 LOCK 前缀(x86)或 独占访问指令(ARM LDREX/STREX)的代码。这些指令不仅原子,还会:
- 强制刷新本核心的写缓冲区。
- 向总线发出 RFO 信号,使其他核心的对应缓存行无效。
- 确保本核心的缓存状态变为 M/E,并且其他核心变为 I。
因此,正确使用原子操作和屏障,就相当于告诉了硬件:“我要对这个变量进行同步访问,请帮我保证一致性。”
反过来,如果你通过普通指令直接访问共享变量(不经过锁或原子操作),硬件不会触发 MESI 的一致性动作,就会读脏数据。
4. 伪共享:当无关的变量成为“冤家”
MESI 的最小单位是缓存行(64 字节)。即使两个变量在逻辑上毫无关系(例如两个不同任务的计数器),只要它们落在同一个缓存行里,就会发生伪共享。
示例:两个核心各自修改 counter0 和 counter1。
struct counters {
int c0; // 核心0 独占使用
int c1; // 核心1 独占使用
} __attribute__((aligned(64))); // 如果不刻意对齐,c0和c1可能在同一缓存行
在内存中,c0 和 c1 很可能都在同一个 64 字节块内。
核心 0 修改 c0 → 需要让核心 1 的缓存行失效。
核心 1 修改 c1 → 需要让核心 0 的缓存行失效。
两者来回发送 RFO,但事实上它们根本不需要同步!
结果就是大量无用的缓存一致性流量,严重降低性能。
4.1 检测伪共享
性能工具(如 Linux 的 perf c2c)可以检测伪共享。一个简单的测试方法是:分别运行两个任务,前一个版本让热点变量分开在独立的缓存行(使用 alignas(64)),后一个版本挤在一起,比较吞吐量。
4.2 消除伪共享的方法
- 变量对齐到缓存行大小(如 64 字节)。
alignas(64) int counter; - 使用填充(padding):在结构体中插入无用字节。
- 将只读变量与读写变量分开,避免读变量和写变量共享行。
- 每个核心使用独立的数组元素,并且元素之间间隔 64 字节。
Linux 内核中常见的 ____cacheline_aligned 宏就是干这个的。
5. 实战:演示伪共享的威力
下面是一个简化示例(多线程 + 伪共享),可以用多核模拟器或真实平台运行。
#define ITERATIONS 100000000
#define CORE_COUNT 2
// 伪共享版本:两个计数器在同一个缓存行
struct {
int counter0;
int counter1;
} shared;
// 对齐版本
struct {
int counter0;
char pad[64 - sizeof(int)];
int counter1;
} aligned __attribute__((aligned(64)));
void *work0(void *arg) {
for (int i = 0; i < ITERATIONS; i++)
shared.counter0++; // 伪共享
return NULL;
}
// work1 类似,修改 shared.counter1
在双核上测试,伪共享版本可能需要数秒,而对齐版本可能快几倍。
如果改成 aligned.counter0++ 和 aligned.counter1++,并且确保两者跨越不同缓存行,那么两个核心基本不会互相干扰。
注意:有些编译器或硬件可能会优化掉无用的赋值循环,可以在
counter前加volatile。
6. 缓存一致性对操作系统设计的影响
- 调度器数据结构:就绪队列的 TCB 链表指针可能会被多个核心访问。为了减少伪共享,可以将 TCB 中经常被不同核心修改的字段(如
state)与不常修改的字段(如name)分开,并确保对齐。 - 时间戳 / 统计计数器:每个核心独立的统计变量应该放在单独的缓存行中,避免核心间干扰。
- 全局锁:自旋锁所在的变量本身必须在一个独占的缓存行中,不要和附近的其他锁或公共数据混在一起。否则,竞争锁时会导致相邻数据也因 RFO 而失效。
Linux 内核中的 DEFINE_PER_CPU 宏为每个核心分配私有数据,并自动处理缓存行对齐,就是为了避免伪共享。
7. 从单核到多核:硬件与软件的共舞
回顾我们走过的路:
- 第 7 讲:多核启动,全局队列受自旋锁保护 – 但忽略了乱序和缓存,导致锁可能失效。
- 第 8 讲:内存屏障和自旋锁的正确实现 – 保证了顺序,但缓存一致性仍是硬件的“潜规则”。
- 第 9 讲:MESI 协议和伪共享 – 只有理解了缓存行和状态机,才能真正写出高性能多核代码。
现在的你,已经能够理解为什么 volatile 在多核下不足以保证同步(它只防止编译器优化,不涉及缓存),为什么自旋锁要配合内存屏障,以及为什么把两个无关变量安排在一起会无故拖慢系统。
8. 本讲小结 & 下集预告
今天我们揭开了 CPU 缓存的神秘面纱:
- MESI 协议通过状态机(M/E/S/I)和总线嗅探,保证多核私有缓存的最终一致性。
- 伪共享 是无心的性能杀手:两个独立的变量共享同一个缓存行,导致无意义的缓存失效颠簸。
- 解决方案:对齐、填充、分离读写变量。
至此,我们已具备构建一个正确且高效的多核操作系统内核所需的所有理论知识。下一讲将是这个专栏的压轴大戏——实战整合。
第10讲:实战与展望——写一个能在双核上跑的“Hello World”调度器
我们将把 TCB、调度算法、自旋锁、内存屏障、缓存对齐等技术融会贯通,在 QEMU 上运行一个真正双核并发的微型内核。你会亲眼看到两个核心同时打印不同的字符串,以及伪共享优化前后的性能差异。
✍️ 思考与练习
- 动手实验:在 Linux 上编写一个多线程程序,两个线程分别递增两个相邻的全局整数,测量执行时间。然后用
alignas(64)分开它们,再次测试。对比结果并解释。 - 查看缓存行大小:用
getconf LEVEL1_DCACHE_LINESIZE(Linux)或 CPUID 指令获取你机器的缓存行大小。 - MESI 画图:画出当核心0 读、核心1 读、核心0 写、核心1 读的完整状态转换序列,标出总线事务。
- 思考:如果一个系统使用 MESI 协议,为什么我们仍然需要内存屏障?(提示:写缓冲区与无效队列会导致顺序问题,屏障用于冲刷它们。)
欢迎在评论区分享你的伪共享调试经历或性能对比数据!下一讲我们写代码,不见不散。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)