缓存(Cache Memory)
缓存(Cache)是CPU内部的高速存储器,填补了CPU和内存之间的速度鸿沟——利用局部性原理,把频繁使用的数据放在CPU"触手可及"的地方
CPU 在等内存
现代 CPU 每秒钟可以执行几十亿条指令,但内存(RAM)的速度跟不上——CPU 执行一条指令需要约 0.25 纳秒,而从内存读数据需要约 50-100 纳秒。这意味着 CPU 大部分时间在等内存。
缓存(Cache Memory) 就是解决这个问题的方法——把最常用的数据放在离 CPU 更近、更快的存储器里。
类比:书桌 vs 书架 vs 图书馆
- 图书馆(内存):什么书都有,但每次借书要走 10 分钟
- 书架(L2 缓存):常用的书放书架上,1 分钟就能拿到
- 书桌(L1 缓存):正在读的书放桌上,伸手就拿
- 口袋(寄存器):手里正在看的这一页——零延迟
局部性原理(Principle of Locality)
缓存能工作的理论基础是局部性——程序访问内存时,不是随机访问的,而是有规律可循的。
时间局部性(Temporal Locality)
如果程序访问了某个地址,不久后很可能再次访问它:
for (int i = 0; i < 1000000; i++) {
sum += x; // x 被反复访问——时间局部性好
}
空间局部性(Spatial Locality)
如果程序访问了某个地址,附近的地址可能很快也会被访问:
for (int i = 0; i < N; i++) {
sum += arr[i]; // arr[0], arr[1], arr[2]... 连续访问——空间局部性好
}
反例:局部性差的代码
// ❌ 局部性极差:按列遍历行优先存储的二维数组
for (int j = 0; j < COLS; j++) {
for (int i = 0; i < ROWS; i++) {
matrix[i][j] = 0; // 跳着访问,空间局部性差
}
}
// ✅ 局部性好:按行遍历
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
matrix[i][j] = 0; // 连续访问
}
}
💡 按行遍历比按列遍历快 10-50 倍——这不是算法复杂度的问题,纯粹是缓存局部性的差异。
缓存层次结构
现代 CPU 有三级缓存:
CPU 核心
│
├── L1 缓存(32 KB ~ 64 KB)
│ ├── L1 指令缓存(L1I):存指令
│ └── L1 数据缓存(L1D):存数据
│ 延迟:约 1 纳秒(4 个 CPU 周期)
│
├── L2 缓存(256 KB ~ 512 KB)
│ 延迟:约 3-5 纳秒(~12 个周期)
│
├── L3 缓存(8 MB ~ 32 MB,多核共享)
│ 延迟:约 10-15 纳秒(~40 个周期)
│
└── 主存(RAM,8 GB ~ 64 GB)
延迟:约 50-100 纳秒(~200 个周期)
大小与速度的关系(金字塔结构):
速度越快 容量越小
╱─────────╲
╱ 寄存器 ╲ ≈ 1 周期, ~1 KB
╰─────────────╯
╱ L1 缓存 ╲ ≈ 4 周期, ~32 KB
╰──────────────────╯
╱ L2 缓存 ╲ ≈ 12 周期, ~256 KB
╰──────────────────────╯
╱ L3 缓存 ╲ ≈ 40 周期, ~16 MB
╰──────────────────────────╯
╱ 主存(RAM) ╲ ≈ 200 周期, ~16 GB
╰────────────────────────────╯
容量越大 速度越慢
🔑 关键数字:L1 比主存快 50 倍。如果一个程序的数据都在 L1 缓存里,理论上可以比数据全在主存里的程序快 50 倍。
缓存的基本工作原理
缓存行(Cache Line)
缓存和内存之间的数据传输单位不是字节,而是缓存行(Cache Line)——通常 64 字节。
// 当你从内存读 arr[0] 时,CPU 实际上把 arr[0]~arr[15](假设 int 4 字节)
// 共 64 字节整条缓存行都加载到缓存了
int sum = arr[0]; // 缓存未命中 → 加载 64 字节
// 接下来访问 arr[1]~arr[15] 都是缓存命中!
sum += arr[1]; // ✅ 缓存命中
sum += arr[2]; // ✅ 缓存命中
这也是为什么空间局部性有效——一次缓存缺失加载了 64 字节,后面的 15 个访问可能都是命中的。
缓存命中(Cache Hit) vs 缓存缺失(Cache Miss)
缓存命中 — 数据在缓存中:
CPU → 请求地址 → 查缓存 → 找到了!→ 立即返回(快)
缓存缺失 — 数据不在缓存中:
CPU → 请求地址 → 查缓存 → 没找到 → 查内存(慢)
↓
加载整条缓存行
↓
写入缓存(可能淘汰旧数据)
↓
CPU 继续执行
缓存映射方式
直接映射(Direct-Mapped)
每个内存地址只能缓存在缓存中的一个固定位置:
类比:图书馆的还书车——每个书号固定放某一格,放满了就把旧的丢掉
内存地址 → 取模运算 → 唯一对应的缓存行
直接映射计算:
缓存行号 = (内存地址 / 64) % 缓存行总数
例:64 KB 缓存,64 字节/行 → 1024 行
地址 0x1000 → 缓存行 (0x1000/64) % 1024 = 第 64 行
地址 0x2000 → 缓存行 (0x2000/64) % 1024 = 第 128 行
优点:简单、快速(硬件实现简单) 缺点:容易冲突(两个常用地址映射到同一位置,互相”踢”)
组相联(Set-Associative)
每个内存地址可以映射到一组缓存行中的任意一个:
类比:停车场——你不需要停到指定车位,可以停在该区域的任意车位
内存地址 → 取模 → 对应的一组(N 个缓存行)→ 选一个空的
| 方式 | 每组行数 | 灵活度 | 硬件复杂度 |
|---|---|---|---|
| 直接映射 | 1 行 | 最低 | 最简单 |
| 2 路组相联 | 2 行 | 中等 | 中等 |
| 4 路组相联 | 4 行 | 高 | 较复杂 |
| 8 路组相联 | 8 行 | 很高 | 复杂 |
| 全相联(TLB 用) | 所有行 | 最高 | 最复杂 |
现代 CPU 的 L1 缓存通常是 8 路组相联,L2/L3 是 16-24 路。
全相联(Fully-Associative)
任何内存地址可以缓存在任何位置——最灵活,但查找最慢(需要比较所有行),所以只在行数很少的场景使用(如 TLB)。
替换策略
当缓存满了,要加载新数据——该淘汰谁?
| 策略 | 做法 | 特点 |
|---|---|---|
| LRU(最近最少使用) | 淘汰最久没用过的 | 效果好,但硬件开销大 |
| FIFO(先进先出) | 淘汰最先来的 | 实现简单,效果一般 |
| 随机 | 随便淘汰一个 | 最简单,意外地效果不错 |
| 伪 LRU | 近似 LRU,用少量位跟踪 | 性价比最高的折中 |
💡 现代 CPU 的 L1/L2 缓存多用伪 LRU——效果接近 LRU,但硬件实现成本低得多。
写策略
当 CPU 修改了缓存中的数据,什么时候把修改写回内存?
写直达(Write-Through)
写到缓存的同时也写到内存:
CPU → 写缓存(快)→ 写内存(慢)
↖ 每次写入都要等内存
优点:缓存和内存总是一致的 缺点:每次写操作都很慢
写回(Write-Back)
只写到缓存,标记为脏,等缓存行被替换时才写回内存:
CPU → 写缓存(快),标记"脏"
...
(需要腾出缓存行时)→ 写回内存
优点:写操作快(多次写同一地址只需一次内存写) 缺点:实现复杂(需要”脏”标志位,多核间有一致性问题)
现代 CPU 的 L1/L2 缓存几乎都使用**写回(Write-Back)**策略。
缓存一致性(多核场景)
多核 CPU 中,每个核心有自己的 L1/L2 缓存——同一个内存地址可能被多个核心缓存。如果核心 A 修改了共享数据,核心 B 的缓存中还是旧值——不一致。
MESI 协议——每个缓存行标记为 4 种状态之一:
| 状态 | 含义 | 本核 | 其他核 |
|---|---|---|---|
| M(Modified,已修改) | 已修改,与内存不一致 | 有最新值 | 没有此数据 |
| E(Exclusive,独占) | 与内存一致 | 有,且仅此核有 | 没有此数据 |
| S(Shared,共享) | 与内存一致 | 有 | 可能有 |
| I(Invalid,无效) | 数据过时 | 没有有效值 | — |
核心 A 写地址 X: 核心 B 读地址 X:
缓存行 → M(已修改) 发现核心 A 有 M 版本
↓
核心 A 写回内存
↓
核心 B 从内存加载
↓
两核都变成 S(共享)
💡 MESI 的硬件实现通过总线嗅探(Bus Snooping)——每个缓存”监听”总线上的内存访问请求,判断自己的缓存行是否需要失效。
缓存对性能的影响:实测
// 对比不同访问模式的性能
#define N 10000000
int arr[N];
// 方案 1:顺序访问(空间局部性好)
for (int i = 0; i < N; i++) arr[i] = i;
// 时间:~30 ms ✅
// 方案 2:跳跃访问(空间局部性差)
for (int i = 0; i < N; i++) arr[(i * 64) % N] = i;
// 时间:~300 ms ❌(慢了 10 倍)
// 方案 3:随机访问(局部性极差)
for (int i = 0; i < N; i++) arr[rand() % N] = i;
// 时间:~3000 ms ❌❌(慢了 100 倍)
缓存友好的编程指南
// 1. 连续访问(线性遍历)
for (int i = 0; i < N; i++) sum += a[i]; // ✅
// 2. 避免步长过大
for (int i = 0; i < N; i += 64) sum += a[i]; // ❌ 每 64 个才访问一次
// 3. 数据紧凑(struct 打包)
struct Bad { int id; char padding[60]; }; // ❌ 浪费缓存行
struct Good { int id; int data; }; // ✅ 一个缓存行塞更多
// 4. 分块处理(loop tiling)
// 对大矩阵,一次处理一小块让小数据在缓存中反复利用
for (int ii = 0; ii < N; ii += BLOCK) {
for (int jj = 0; jj < N; jj += BLOCK) {
for (int i = ii; i < ii + BLOCK; i++) {
for (int j = jj; j < jj + BLOCK; j++) {
c[i][j] = a[i][j] + b[i][j];
}
}
}
}
小结
缓存是计算机体系结构中最重要的性能优化机制之一:
| 概念 | 要点 |
|---|---|
| 局部性原理 | 时间和空间局部性是缓存能工作的理论基础 |
| 缓存层次 | L1(最快最小)→ L2 → L3(最慢最大)→ 主存 |
| 缓存行 | 传输单位 64 字节,一次缺失带回来一堆 |
| 映射方式 | 直接映射(简单易冲突)→ 组相联(折中)→ 全相联(灵活但慢) |
| 写策略 | 写直达(简单但慢)vs 写回(复杂但快) |
| MESI 协议 | 多核场景下的缓存一致性保证 |
为什么这很重要? 缓存是”看不见的内存层级”——程序员不需要显式管理它,但它的行为深刻影响程序性能。理解了缓存,你就能写出比不懂缓存的人快 10 倍的代码。
接下来,你将看到缓存如何融入更大的 CPU 架构:数据到底是怎么在 CPU 内部流动的?——CPU 数据通路。