缓存一致性是计算机系统中一个至关重要却又常常被忽视的概念。它不仅仅是硬件工程师需要关心的问题,对于编写高性能代码的软件开发者来说,深入理解其原理与影响同样不可或缺。缓存一致性主要涉及以下几个层面:
- 一个CPU的指令缓存(icache)和数据缓存(dcache)的同步问题。
- 多个CPU各自的缓存同步问题。
- CPU与设备(在Linux CPU看来,设备都是DMA)之间的缓存同步问题。

一、指令缓存与数据缓存的同步
首先来看ICACHE和DCACHE的同步问题。程序运行时,指令流经过icache,而指令涉及的数据流经过dcache。对于自修改代码(Self-Modifying Code,常见于JIT编译器),当我们修改内存中某地址P的指令时,是通过存储(store)操作写入的,因此新指令会进入dcache。但接下来执行P地址的指令时,icache中命中的可能还是修改前的旧指令。

因此,软件需要将dcache中的数据写回(clean)内存,并使icache相应部分失效(invalidate),这个开销相当大。不过,像ARM64的Neoverse N1处理器已支持硬件的icache一致性,无需软件维护,消除了广播icache失效操作带来的可扩展性瓶颈。

二、多核之间的缓存同步与MESI协议
接下来是多核之间的缓存同步问题。下图是一个简化的处理器模型,反映了缓存的基本层级关系,实际的NUMA架构会更复杂。

如果CPU_A读取了地址P的变量,CPU_B、C、D又读,难道它们都必须从RAM经L3、L2、L1再读一遍吗?显然不必。硬件上的缓存嗅探(snooping)控制单元,可以直接将CPU_A的P地址缓存行拷贝到其他CPU的缓存中。

这样所有CPU都得到了P地址的数据。但如果CPU B此时修改了这个数据,而其他CPU缓存中仍是旧值,就会产生不一致。

这就需要一套协议来维护一致性,典型的有MESI和MOESI协议。MOESI与MESI有些细微差异,但不影响全局理解。我们重点看MESI协议。
MESI协议定义了4种状态:
- M(Modified):缓存行有效,数据已被修改且与内存不一致,数据仅存在于当前缓存。
- E(Exclusive):缓存行有效,数据与内存一致,且数据仅存在于当前缓存。
- S(Shared):缓存行有效,数据与内存一致,且数据存在于多个缓存中。
- I(Invalid):缓存行无效。
下图展示了这几种状态的示例:



MESI协议通过一个状态机来管理这些状态的转换,该状态机监控所有CPU的缓存读写操作。

状态机的核心目的是保证一致性。例如,最初A、B、C CPU共享一个干净的数据(S状态)。当CPU B写入修改后,其状态变为M。此时硬件会自动将A、C CPU中该缓存行置为无效(I状态),无需软件干预。

如果此时CPU C要读这个数据,而它在B中是M状态(与RAM不一致),硬件会将B的修改写回内存,然后B、C的状态都变为S,数据恢复一致。

这一系列硬件自动完成的操作并非没有代价,它会消耗时间。如果编程不当,引发大量缓存同步,程序性能会急剧下降。
感知缓存同步的开销:一个实验
下面这个程序有两个线程,一个写变量,一个读变量,且变量是缓存行对齐的。
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
static int x __attribute__((aligned (64)));
static int y __attribute__((aligned (64)));
void* thread_fun1(void* param)
{
for (int i = 0; i < 1000000000; ++i)
x++;
return NULL;
}
void* thread_fun2(void* param)
{
volatile int c;
for (int i = 0; i < 1000000000; ++i)
c = x;
return NULL;
}
int main()
{
pthread_t tid1,tid2;
pthread_create(&tid1,NULL,thread_fun1,NULL);
pthread_create(&tid2,NULL,thread_fun2,NULL);
pthread_join(tid1,NULL);
pthread_join(tid2,NULL);
}

这个程序中,thread1的写操作会不断与thread2的读操作进行缓存同步。其执行时间如下:
$ time ./a.out
real 0m3.614s
user 0m7.021s
sys 0m0.004s
如果在两个CPU上运行,累计用户态时间7.021秒,真实时间3.614秒。
如果将thread2中的c = x改为c = y,两个线程读写的是不同的缓存行,就避免了硬件的缓存同步开销。

修改后的运行时间:
$ time ./b.out
real 0m1.820s
user 0m3.606s
sys 0m0.008s
现在只需要1.8秒,几乎快了一倍。
更有趣的是,如果用单核运行第一个程序:
$ time taskset -c 0 ./a.out
real 0m3.299s
user 0m3.297s
sys 0m0.000s
单核跑完甚至比双核(3.614s)还快!因为单核内没有跨核的缓存同步开销。这个例子深刻揭示了错误共享(False Sharing)对性能的毁灭性影响。
三、CPU与设备之间的缓存同步
另一个缓存同步的重大问题是设备与CPU之间。如果设备感知不到CPU的cache,那么在进行DMA前后,CPU就需要进行cache清理(clean)和失效(invalidate)操作,软件开销很大。

在Linux编程时,如果使用流式DMA API(如dma_map_single(), dma_unmap_single()等),这些同步操作会被自动处理。如果使用dma_alloc_coherent() API,则CPU与设备之间的缓冲区是缓存一致的。对于不支持硬件一致性的设备,该API可能将对应的CPU内存访问设置为不可缓存(uncachable)。
更好的硬件实现是让总线协议支持设备嗅探CPU缓存。

- 做内存到外设的DMA时,直接从CPU缓存中取已修改(M状态)的数据。
- 做外设到内存的DMA时,直接使CPU中相应的缓存行失效。
这样就实现了硬件级的缓存同步。当然,这种同步仍会消耗总线周期。同步开销还与距离相关,下图中A、B间的同步开销就比A、C小。在NUMA服务器中,跨NUMA的缓存同步开销远大于NUMA内部。

四、缓存感知编程与性能优化
意识到缓存问题并据此进行编程,对开发者至关重要。从CPU流水线角度看,内存访问延迟可简化为:平均访问延迟 = 命中时间 + 未命中率 × 未命中惩罚。缓存未命中会导致CPU停顿(stall)。
现代CPU微架构分为前端(frontend)和后端(backend)。前端取指令给后端执行,后端执行依赖于运算能力和内存子系统(包括缓存)的延迟。

后端执行中访问数据导致的缓存未命中会导致后端停顿,从而降低每周期指令数(IPC)。
1. 缓存预取(Prefetch)
减少缓存未命中是软硬件协同的任务。硬件支持预取,通过分析未命中模式提前取数据。对于复杂无规律的数据,则需要软件使用预取指令来提示CPU。例如在ARM上,可以使用PLD指令。
Linux内核中大量使用了预取来提升性能。例如下面这个WiFi驱动提交,在收到skb后立即预取其数据,以便后续包分类和IP栈处理时能命中缓存。

预取的原理类似“周末让北京同事飞过来,避免周一开会等待”。下面以经典的二分查找代码为例,展示预取的效果。

代码中#ifdef DO_PREFETCH部分提前预取了下次迭代可能访问的中间值。对比开启和关闭预取的性能:
# 开启预取
real 0m10.000s
user 0m9.611s
sys 0m0.389s
# 关闭预取
real 0m11.606s
user 0m11.186s
sys 0m0.420s
开启预取后性能提升约14%。
使用性能工具(如perf和pmu-tools)进行自上而下(Topdown)分析,可以清晰看到差异。在不预取的情况下,程序后端受限严重,其中DRAM_Bound占比高达75.8%。开启预取后,DRAM_Bound占比下降至60.7%。
通过perf stat监控cycle_activity.stalls_l3_miss事件,可见开启预取后,尽管指令数增多,但每周期指令数(IPC)提升,总周期数减少,关键原因就是L3未命中停顿周期大幅减少。
进一步使用perf record -e mem_load_retired.l3_miss定位热点,不预取时,99.93%的L3未命中发生在main函数,注解汇编后可见热点在array[mid] < key这一内存访问。预取后,main函数的占比降至80%,热点被分散,内存瓶颈得到缓解。


2. 避免伪共享(False Sharing)
如前文实验所示,伪共享问题会引发剧烈的缓存同步。解决方法是让可能被不同线程频繁读写的数据处于不同的缓存行。例如,对于一个结构体:
struct s {
int a;
int b;
};
如果线程1写a,线程2写b,就可能产生伪共享。可以通过填充(padding)来隔离它们:
struct s {
int a;
char padding[L1_CACHE_BYTES - sizeof(int)];
int b;
};
Linux内核中有大量此类优化。例如下面这个提交,在tw_count后面填充60字节,以避免与后续数据伪共享。


另一个例子是通过调整结构体成员顺序来优化。page_counter结构体中,频繁写的usage和频繁读的parent原本靠得很近,导致伪共享。将parent移至末尾后,性能得到显著提升(最高达9.9%)。


五、CPU缓存基础概念回顾
为了更全面地理解缓存一致性,我们有必要回顾一些关于计算机体系结构中CPU缓存的基础知识。
1. 什么是CPU缓存?
CPU缓存是CPU内部的高速缓存。当CPU从内存读取数据时,会读取一个缓存行(通常是64字节)到高速缓存中。后续访问相邻数据时,可直接从缓存读取,速度远快于访问内存。

2. 为什么需要多级缓存?
引入缓存的理论基础是程序局部性原理(时间局部性和空间局部性)。由于速度越快的存储设备成本越高,单纯增大一级缓存性价比低。因此采用多级缓存架构:速度越快、容量越小、越接近CPU的缓存级别越高(如L1),反之则速度较慢、容量较大、成本较低(如L3)。


3. 缓存大小与速度
通常L1 Cache最小最快(每个核心独有指令和数据缓存),L2 Cache较大较慢(每个核心独有),L3 Cache最大最慢(所有核心共享)。访问延迟逐级递增。



4. 缓存行(Cache Line)
缓存行是缓存与内存之间数据传输的最小单位,通常是64字节。
5. 写入策略
- 直写(Write Through):数据同时写入缓存和内存,简单但慢。
- 回写(Write Back):数据只写入缓存,仅在被替换时才写回内存,速度快,但需要MESI这类一致性协议保证数据一致性。
6. 缓存一致性(Cache Coherence)
多个CPU读写同一内存区域引起的冲突问题。MESI协议通过使其他CPU缓存失效并回写脏数据来解决。
7. 缓存映射方式
- 直接映射:每个主存块只能映射到缓存的一个特定位置。简单快,但易冲突。
- 全相联映射:主存块可映射到缓存任意位置。灵活,命中率高,但查找电路复杂。
- 组相联映射:直接映射与全相联的折中。缓存分组,组间直接映射,组内全相联。最常用。

8. 替换策略
最常用的是LRU(最近最少使用)算法。
9. 编程实践:利用局部性
遍历二维数组时,按行遍历比按列遍历快得多,因为按行遍历更好地利用了空间局部性,提高了缓存命中率。
// 按行遍历 - 缓存友好
for (int r = 0; r < row; r++) {
for (int c = 0; c < col; c++) {
sum += matrix[r][c];
}
}
// 按列遍历 - 缓存不友好
for (int c = 0; c < col; c++) {
for (int r = 0; r < row; r++) {
sum += matrix[r][c];
}
}
总结
缓存一致性是一个涉及硬件和软件的深度话题。理解MESI等一致性协议的工作原理,认识到伪共享、缓存未命中带来的性能损失,并学会使用预取、数据对齐与填充等优化技巧,是编写高性能多线程程序的关键。在现代多核乃至众核时代,具备良好的缓存感知编程能力,能让开发者更好地驾驭硬件潜力,避免陷入“内卷却卷不赢”的境地。如果你想与更多开发者交流此类底层性能优化经验,欢迎访问云栈社区。