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里——这就是经典的伪共享(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到I那条线,写了个“写回内存”——这就是关键。M状态的Line被踢出去之前,必须先把脏数据写回内存,否则数据就丢了。
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、应用处理器 |
2.4 三者如何协同工作
好,我们把三个概念串起来。一个典型的多核系统里:
- Cache Line 是数据搬运的集装箱,64字节一箱。
- MESI协议 是集装箱的管理员,决定每个箱子在哪个核手里是什么状态。
- 写策略 是装卸工的规矩——写回策略下,箱子脏了先堆着,等管理员说“该腾地方了”再搬回内存。
举个例子:核A要写地址0x1000,该地址的Cache Line当前在核B手里是M状态。那么:
- 核B收到Invalidate请求,把自己的脏Line写回内存(写回策略生效)。
- 核B的Line状态从M变为I。
- 核A从内存加载最新数据,状态变为E(如果没其他核读)或S(如果有其他核读)。
- 核A写数据,状态从E变为M。
你看,三个概念缺一不可。没有Cache Line,协议不知道操作多大范围;没有MESI,不知道数据在谁手里;没有写策略,脏数据不知道什么时候该落地。
嗯,这一章的内容就到这儿。缓存基础是后面所有一致性协议的基石,尤其是MESI的状态转换,我建议你多画几遍。下一章我们会看到MESI在实际总线上的具体实现——嗅探协议和目录协议,那才是真正考验架构师功底的地方。
公众号:蓝海资料掘金营,微信deep3321