内存架构基础:CPU缓存层级(L1/L2/L3)、缓存行与伪共享、TLB与页表
聊内存优化,绕不开CPU缓存。很多同学写代码时觉得内存就是内存,CPU直接读写就完事了。其实不是这样。CPU和内存之间,隔着好几层缓存,每一层都有它的脾气。
我刚开始做性能调优那会儿,就吃过不懂缓存的亏。一个多线程程序,怎么优化都跑不快,最后发现是伪共享在作祟。从那以后,我养成了一个习惯——写性能敏感代码前,先画一张缓存层级图。
CPU缓存层级:为什么需要L1/L2/L3?
先看一张图,帮你快速建立整体认知。
CPU缓存的核心逻辑很简单:离核心越近,访问越快,但容量越小。具体来说:
- L1缓存:分指令缓存(L1i)和数据缓存(L1d),典型大小32KB-64KB。访问延迟约3-5个CPU周期。每个核心独享。
- L2缓存:统一缓存指令和数据,典型大小256KB-512KB。访问延迟约10-15个周期。每个核心独享。
- L3缓存:所有核心共享,典型大小8MB-32MB。访问延迟约30-50个周期。
缓存行与伪共享:多线程性能的隐形杀手
CPU从内存读数据,不是按字节读的,而是按缓存行(Cache Line)为单位。现代x86 CPU的缓存行大小通常是64字节。也就是说,你只想要一个int,CPU却把周围64字节一股脑全搬进缓存。
这本身是好事——利用了空间局部性。但多线程场景下,它可能变成噩梦。
什么是伪共享?
假设两个线程分别操作两个不同的变量,但这两个变量恰好在同一个缓存行里。线程A修改变量a,导致整个缓存行失效;线程B想读变量b,发现缓存行失效了,只能重新从内存加载。明明操作的是不同数据,却因为共享了同一个缓存行而互相拖累。
这就是伪共享(False Sharing)。
核心问题: 缓存一致性协议(如MESI)以缓存行为单位维护状态。只要缓存行内任何一个字节被修改,整个缓存行在其他核心的副本都会失效。
我曾经优化过一个日志系统,多线程写日志时吞吐量上不去。用perf一看,L1缓存miss率异常高。排查后发现,日志缓冲区里的几个计数器挨在一起,正好落在同一个缓存行里。每个线程写自己的计数器,结果互相踢缓存行。嗯,典型的伪共享。
解决方案?给每个计数器前后填充字节,让它们独占一个缓存行。
// 伪共享示例:两个变量在同一个缓存行
struct Bad {
int a; // 线程1操作
int b; // 线程2操作
}; // a和b很可能在同一个缓存行内
// 修复:缓存行填充
struct Good {
int a;
char padding[60]; // 填充到64字节
int b;
char padding2[60];
};
TLB与页表:虚拟地址到物理地址的桥梁
现代操作系统使用虚拟内存。每个进程有自己的虚拟地址空间,CPU需要把虚拟地址翻译成物理地址。这个翻译工作依赖页表(Page Table)。
页表本身存在内存里。每次内存访问都要查一次页表,那性能就完蛋了——等于每次访问都多了一次内存访问。所以CPU引入了TLB(Translation Lookaside Buffer),也就是页表的缓存。
| 层级 | 典型大小 | 覆盖范围 | 命中延迟 |
|---|---|---|---|
| L1 TLB | 64-128 条目 | 256KB-512KB (4KB页) | 1个周期 |
| L2 TLB | 512-1024 条目 | 2MB-4MB (4KB页) | 5-10个周期 |
| 页表遍历 | — | — | 100+个周期 |
TLB miss的代价非常高。一次TLB miss意味着CPU要走页表遍历流程,可能需要访问多级页表,耗时上百个周期。
大页(Huge Pages)的作用
标准页大小是4KB。如果程序访问了2MB的连续内存,需要512个页表条目。TLB根本装不下。但如果用2MB的大页,只需要1个条目。TLB的覆盖范围瞬间扩大512倍。
我优化过一个内存数据库,数据量几十GB,随机访问模式。启用2MB大页后,TLB miss率从15%降到了不到1%,查询延迟直接降了30%。
echo always > /sys/kernel/mm/transparent_hugepage/enabled 开启透明大页,或者用 mmap 配合 MAP_HUGETLB 手动分配。
一张图总结本章核心
这三个概念是内存性能优化的基石。你想想看,不懂缓存层级,你怎么知道数据该放多大?不懂缓存行,你怎么排查多线程性能问题?不懂TLB,你怎么理解大页的价值?
我个人习惯,拿到一个新系统,第一件事就是看它的内存访问模式。连续访问还是随机访问?工作集多大?线程间有没有共享数据?这些信息直接决定了该用哪种优化手段。