46. 缓存友好:局部性原理、缓存行、伪共享

说到内存性能优化,有个话题绕不开——CPU缓存。我见过不少团队,代码写得挺漂亮,算法复杂度也低,但跑起来就是慢。一查,发现是缓存没用好。说白了,你的数据在内存里怎么摆,CPU怎么拿,直接影响程序速度。

今天咱们就聊聊缓存友好的三个核心概念:局部性原理缓存行伪共享。搞懂这些,你写出来的代码性能能上一个台阶。

局部性原理:你的程序有“惯性”

局部性原理分两种:时间局部性和空间局部性。

  • 时间局部性:刚访问过的数据,很可能马上再访问一次。比如循环里的变量。
  • 空间局部性:刚访问过的数据附近的数据,很可能接下来被访问。比如遍历数组。

CPU缓存就是利用这个原理工作的。它会把你要的数据和它“邻居”一起加载到缓存里。如果你的访问模式符合局部性,缓存命中率就高,程序就快。

核心观点:写代码时,尽量让数据访问有规律、连续。别跳来跳去,那是在跟缓存作对。

我在项目中遇到过一件事:一个图像处理模块,原本用链表存储像素块,处理速度一直上不去。改成连续数组后,速度提升了将近3倍。为什么?链表节点在内存里东一个西一个,缓存根本没法预取。数组就不一样了,连续内存,CPU一次加载一整片,爽得很。

缓存行:CPU搬运数据的最小单位

CPU不一个字节一个字节地从内存拿数据。它一次拿一整块,这块就叫缓存行。现代CPU的缓存行大小通常是64字节。

你想想看,如果你只访问一个4字节的int,CPU会把包含这个int的64字节全部搬进缓存。这64字节里可能还有别的数据。如果你接下来访问这些“邻居”,那就直接命中缓存,快得飞起。

小技巧:结构体设计时,把经常一起访问的成员放在一起。这样它们大概率落在同一个缓存行里,访问效率高。

举个例子:

// 不友好的设计
struct Bad {
    int id;          // 经常访问
    char padding[60]; // 凑数
    int score;       // 经常访问
};

// 友好的设计
struct Good {
    int id;          // 经常访问
    int score;       // 经常访问,紧挨着id
    char padding[56]; // 剩下的填充
};

Good里,idscore大概率在同一个缓存行里。访问完id再访问score,几乎零成本。而Bad里它们隔了60字节,很可能跨了两个缓存行,性能就差远了。

伪共享:多线程下的隐形杀手

伪共享是个很有意思的问题。它不涉及数据竞争,没有读写冲突,但性能就是上不去。

伪共享发生在多线程场景下。两个线程各自操作不同的变量,但这两个变量不幸落在了同一个缓存行里。线程A修改自己的变量,导致整个缓存行失效。线程B想读自己的变量,发现缓存行失效了,只好重新从内存加载。反过来也一样。两个线程互相“踢”对方的缓存,性能直线下降。

注意:伪共享不是数据竞争,没有逻辑错误,但性能损失巨大。我曾经在一个高并发服务里排查性能瓶颈,发现一个计数器数组导致严重的伪共享,修复后吞吐量提升了40%。

来看一个典型例子:

struct Counter {
    int value;
};

Counter counters[4]; // 假设4个线程各用一个

// 线程1操作 counters[0]
// 线程2操作 counters[1]
// 它们很可能在同一个缓存行里!

解决办法是缓存行填充。让每个变量独占一个缓存行,互不干扰。

struct AlignedCounter {
    int value;
    char padding[60]; // 填充到64字节
};

// 或者用C++17的alignas
struct alignas(64) AlignedCounter {
    int value;
};

这样每个AlignedCounter都独占一个缓存行,线程之间就不会互相影响了。

知识体系图

下面这张图帮你理清这三个概念的关系:

缓存友好核心概念 局部性原理 缓存行 伪共享 驱动 引发 时间局部性 空间局部性 大小:64字节 预取机制 缓存行失效 性能抖动 总结:利用局部性原理设计数据布局 理解缓存行大小,避免伪共享

实践建议

  1. 优先用连续容器std::vectorstd::list 缓存友好。遍历时,vector的缓存命中率远高于list。
  2. 结构体成员按访问频率排序:高频访问的成员放前面,低频的放后面。这样高频成员更容易落在同一个缓存行里。
  3. 多线程数据隔离:每个线程的数据尽量独立,不要共享缓存行。必要时用缓存行填充。
  4. 避免随机访问:如果必须随机访问,考虑用哈希表或B树等结构,它们对缓存相对友好一些。

我的习惯:写性能敏感的代码时,我会先用perfvalgrind --tool=cachegrind看看缓存命中率。如果命中率低于90%,我就会检查数据布局。别凭感觉优化,拿数据说话。

嗯,关于缓存友好就聊这么多。记住一句话:数据怎么放,决定了CPU怎么拿。把数据摆整齐了,性能自然就上去了。


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