2、缓存行与局部性原理:缓存行大小、空间局部性与时间局部性、缓存命中与缺失
2.1 缓存行:CPU缓存的最小工作单元
聊缓存,第一个绕不开的概念就是缓存行(Cache Line)。
我刚开始做性能优化时,有个误区——以为CPU从内存读数据是按字节来的。后来被一个性能问题狠狠教育了一顿。当时一个循环处理数组,怎么优化都跑不满预期,最后用perf一看,缓存缺失率高得吓人。查了资料才明白:CPU从来不会只读一个字节,它一次就读一整条缓存行。
缓存行的大小,在x86架构下通常是64字节。ARM架构也类似,但有些嵌入式核可能是32字节。说白了,这就是CPU和内存之间数据交换的“基本单位”。你读一个int,4个字节,但CPU会连它附近的60个字节一起拉进缓存。
2.2 空间局部性:邻居的数据更可能被用到
空间局部性,说白了就是“如果你用了一个地址,它附近的地址很快也会被用到”。
为什么会这样?因为程序的数据在内存里往往是连续存放的。数组遍历就是最典型的例子。你访问a[0],很快就要访问a[1]、a[2]……
我举个例子你就明白了:
// 空间局部性好 —— 按行遍历
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
sum += matrix[i][j];
}
}
// 空间局部性差 —— 按列遍历
for (int j = 0; j < N; j++) {
for (int i = 0; i < N; i++) {
sum += matrix[i][j];
}
}
第一个版本,每次读入一个缓存行(64字节),就能连续处理16个int(假设int是4字节)。第二个版本呢?每次跳着访问,缓存行里只用到1个int,剩下的15个全浪费了。性能差距轻松达到10倍以上。
2.3 时间局部性:刚用过的数据很快会再用
时间局部性更直观——你刚访问过的数据,短时间内很可能再次被访问。
想想看,循环里的变量、频繁调用的函数代码、栈上的局部变量……这些都是时间局部性的典型例子。CPU的L1缓存之所以能做到1-2个时钟周期的访问延迟,就是靠这个原理。
我遇到过一个问题:一个服务端的热点函数,每次调用都要从内存加载一堆配置数据。明明这些配置在短时间内不会变,但代码就是没做缓存。后来我在函数入口加了个静态局部变量做缓存,QPS直接翻了一倍。
2.4 缓存命中与缺失:性能的分水岭
缓存命中(Cache Hit)和缓存缺失(Cache Miss),是衡量程序内存访问效率的核心指标。
命中,就是你要的数据已经在缓存里了。缺失,就是没在,得去下一级缓存甚至内存里拿。
缓存缺失主要有三种类型:
| 缺失类型 | 原因 | 典型场景 |
|---|---|---|
| 强制缺失(Compulsory Miss) | 第一次访问数据,缓存里肯定没有 | 程序启动、首次加载文件 |
| 容量缺失(Capacity Miss) | 工作集太大,缓存装不下 | 处理超大数组、数据库全表扫描 |
| 冲突缺失(Conflict Miss) | 多个地址映射到同一个缓存行位置 | 步长为2的幂次的大数组访问 |
嗯,这里要注意——冲突缺失是最容易被忽视的。我曾经调试过一个网络包处理程序,性能忽高忽低。后来发现是两个热数据结构恰好映射到了同一个缓存组,互相踢来踢去。解决办法就是在结构体之间填充一些字节,让它们错开。
2.5 如何利用局部性原理写出缓存友好的代码
说了这么多理论,来点实际的。我个人习惯在写代码时遵循几条原则:
- 数据尽量连续存放——用数组代替链表,用结构体数组代替数组结构体。
- 按访问顺序布局内存——热点字段放在结构体开头,冷热数据分离。
- 循环内避免跳跃访问——步长尽量小,最好就是1。
- 分块处理大数据集——让每个数据块能完整塞进L1/L2缓存。
- 对齐缓存行边界——关键数据结构按64字节对齐,避免跨行访问。
举个例子,一个网络包处理的结构体,我通常会这样设计:
// 缓存友好的结构体布局
struct __attribute__((aligned(64))) packet {
// 热点字段:每次处理都会访问
uint32_t len;
uint32_t flags;
uint64_t timestamp;
// 填充到缓存行边界
char padding[48];
// 冷数据:只在异常时访问
char error_msg[64];
};
这样设计,热点数据都在同一个缓存行里,处理时一次加载就够了。冷数据被隔离到另一个缓存行,不会污染热点。
perf stat -e cache-references,cache-misses ./your_program。如果缓存缺失率超过5%,就该考虑优化了。
最后说一句——理解缓存行和局部性原理,是写出高性能代码的基础。你想想看,现代CPU的主频已经很难再大幅提升了,性能增长主要靠缓存和多核。如果你写的代码总是触发缓存缺失,那再快的CPU也救不了你。
下一章,我们会深入MESI协议,看看多核之间到底是怎么维护缓存一致性的。到时候你会发现,伪共享只是冰山一角。