2. 缓存基础回顾:Cache Line、MESI协议基础、写策略(写直达/写回)

好,我们正式开始第二讲。在深入多核一致性之前,得先把缓存的老底儿翻出来晒晒。你想想看,如果连Cache Line多大、MESI怎么握手都搞不清楚,后面谈一致性协议就是空中楼阁。

我个人习惯,讲缓存必先讲三个核心:存储粒度(Cache Line)、状态机(MESI)、写策略(写直达/写回)。这三板斧砍明白了,后面就好办了。

2.1 Cache Line:缓存的最小作战单元

缓存不是按字节存的,是按“块”存的。这个块就叫Cache Line。主流CPU的Cache Line大小通常是64字节,也有少数用32或128字节的。

为什么是64字节?说白了,这是空间局部性和传输开销的折中。太小了,预取效率低;太大了,带宽浪费。

核心要点: Cache Line是缓存一致性协议操作的最小单位。两个核哪怕只改同一个Line里的不同字节,也得走完整的一致性握手流程。

我在项目中遇到过一个问题:两个线程各自修改一个结构体的不同字段,结果性能惨不忍睹。一查,两个字段刚好落在同一个Cache Line里——这就是经典的伪共享(False Sharing)

来看一个典型的Cache Line结构:

// 64字节的Cache Line,通常包含:
// [Valid Bit | Tag | Data Block (64B)]
// 有些实现还会包含 Dirty Bit 和 LRU 状态位

// 伪共享示例(C++)
struct alignas(64) SharedData {
    int thread1_counter;  // 核1频繁写
    int thread2_counter;  // 核2频繁写
    char padding[56];     // 补齐到64字节
};

嗯,这里要注意:alignas(64) 强制让结构体按Cache Line对齐,避免两个计数器挤在同一个Line里。我当年调试一个网络转发程序,就是因为没做对齐,吞吐量直接腰斩。

2.2 MESI协议基础:四个状态的握手舞

多核缓存一致性,说白了就是让所有核看到同一份内存的“最新版本”。MESI协议是最经典的解决方案,定义了四种状态:

状态 全称 含义 该Line是否脏 其他核是否有副本
M Modified 本核独有,已修改,与内存不一致
E Exclusive 本核独有,未修改,与内存一致
S Shared 多核共享,未修改,与内存一致
I Invalid 无效,本核无有效数据 - -

为什么要有E状态?你想想看,如果一个核独占某Line且没改过,那它读的时候根本不用通知别人。只有写的时候才需要广播。E状态就是用来省掉那一次不必要的总线通信的。

我曾经调试过一个ARM多核系统,发现某个核写数据时总是卡顿。抓总线日志一看,每次写之前都发了一堆Invalidate请求。原因就是协议实现里把E状态误判成了S状态,导致每次写都要先做无效化广播。修复后性能提升30%。

状态转换的核心逻辑,我用SVG画了一张图:

M Modified E Exclusive S Shared I Invalid 本地读(无其他副本) 本地读(有其他副本) 本地写 远端读 远端写 远端写 其他核全失效 远端读/写(写回内存) 远端读(写回内存) 图例: 写操作 读操作 条件转换

这张图我画得比较细。你看M到I那条线,写了个“写回内存”——这就是关键。M状态的Line被踢出去之前,必须先把脏数据写回内存,否则数据就丢了。

避坑指南: 我曾经遇到一个bug,某个核的M状态Line被远端读请求命中时,设计上直接丢弃了本地修改。结果就是内存里的数据还是旧的,读到的核拿到了垃圾数据。记住:M状态被驱逐前,必须写回

2.3 写策略:写直达 vs 写回

写策略决定了CPU写Cache时,要不要同步写内存。两种策略各有千秋。

2.3.1 写直达(Write-Through)

写直达的策略很简单:每次写Cache,同时写内存。优点是实现简单,内存永远是最新的。缺点是每次写都要访问内存,性能拉胯。

// 写直达的伪代码
void write_through(uint64_t addr, uint32_t data) {
    cache[addr].data = data;   // 写Cache
    memory[addr] = data;       // 同时写内存(慢!)
}

我记得早期的一些嵌入式CPU用写直达,因为总线仲裁简单。但在高性能场景下,写直达基本绝迹了——你想想看,CPU写一个Cache Line 64字节,每次都要穿透到内存,带宽根本扛不住。

2.3.2 写回(Write-Back)

写回就聪明多了:只写Cache,标记为脏(Dirty),等Line被替换时才写回内存

// 写回的伪代码
void write_back(uint64_t addr, uint32_t data) {
    cache[addr].data = data;   // 只写Cache
    cache[addr].dirty = 1;     // 标记为脏
    // 内存先不管,等替换时再说
}

// 当Cache Line被替换时
void evict_line(int line_idx) {
    if (cache[line_idx].dirty) {
        memory[cache[line_idx].addr] = cache[line_idx].data; // 写回内存
    }
    // 然后加载新数据
}

写回的优点是大幅减少内存访问次数。缺点是实现复杂——需要Dirty位,替换策略要考虑脏Line的写回开销。

对比项 写直达 写回
写内存时机 每次写Cache都写 仅Cache Line被替换时写
内存一致性 始终一致 可能不一致(有脏数据)
写带宽消耗
实现复杂度 高(需Dirty位、替换策略)
典型应用 低端MCU、IO设备 高性能CPU、应用处理器
个人经验: 我在设计一个异构加速器时,加速器本地SRAM用了写直达,因为加速器写模式很规律(流式写),写直达反而省掉了脏位管理的开销。而CPU侧L2 Cache必须用写回,否则多核共享时的带宽会爆炸。没有银弹,看场景选。

2.4 三者如何协同工作

好,我们把三个概念串起来。一个典型的多核系统里:

  1. Cache Line 是数据搬运的集装箱,64字节一箱。
  2. MESI协议 是集装箱的管理员,决定每个箱子在哪个核手里是什么状态。
  3. 写策略 是装卸工的规矩——写回策略下,箱子脏了先堆着,等管理员说“该腾地方了”再搬回内存。

举个例子:核A要写地址0x1000,该地址的Cache Line当前在核B手里是M状态。那么:

  • 核B收到Invalidate请求,把自己的脏Line写回内存(写回策略生效)。
  • 核B的Line状态从M变为I。
  • 核A从内存加载最新数据,状态变为E(如果没其他核读)或S(如果有其他核读)。
  • 核A写数据,状态从E变为M。

你看,三个概念缺一不可。没有Cache Line,协议不知道操作多大范围;没有MESI,不知道数据在谁手里;没有写策略,脏数据不知道什么时候该落地。

一句话总结: 缓存一致性,就是让所有核的Cache Line状态机,按照统一的写策略,协同维护同一份内存视图。

嗯,这一章的内容就到这儿。缓存基础是后面所有一致性协议的基石,尤其是MESI的状态转换,我建议你多画几遍。下一章我们会看到MESI在实际总线上的具体实现——嗅探协议和目录协议,那才是真正考验架构师功底的地方。


公众号:蓝海资料掘金营,微信deep3321