操作系统内功 · 第 18 篇|CPU 缓存与伪共享:缓存行、MESI 与 false sharing 的定位与消除

缓存层次:为什么内存访问速度可以差 100 倍

现代 CPU 的内存访问不是均匀的:L1 命中约 4 个时钟周期(~1ns),L2 约 12 周期(~4ns),L3 约 40 周期(~15ns),落到 DRAM 则是 200–300 周期(~80ns)。从 L1 到内存,单次访问的延迟差接近两个数量级——这意味着代码里最该优化的不是指令数,而是数据的「温度」:让热数据住在离核心最近的缓存里。

CPU 与内存之间搬运数据的最小单位不是字节,而是缓存行(cache line),x86-64 上是 64 字节。你读 1 个字节,CPU 会把整个 64 字节行搬进 L1;写 1 个字节,缓存一致性协议同样以 64 字节为单位在核间同步。这个粒度决定了本篇的两个主角:空间局部性带来的预取收益,和接下来要讲的伪共享惩罚。

缓存的组织是「组相联」的:内存地址按位划分出组号,每组只有 N 个行槽位。不同地址映射到同一组的现象就是缓存冲突——某些数组步长恰好是「缓存大小 / 相联度」的倍数时,访问会互相踢出行槽,性能骤降。这类问题定位靠 perf stat -e cache-misses 与 perf c2c,不是靠猜。

MESI:缓存一致性的四个状态

多核各自有 L1/L2,同一地址的数据可能同时存在于多个核的私有缓存里——必须有一致性协议保证大家看到一致的值。主流实现是 MESI 四态协议,每个缓存行处于四态之一:

stateDiagram-v2
    M: Modified(已修改,独占且脏)
    E: Exclusive(独占,干净)
    S: Shared(多核共享,干净)
    I: Invalid(失效)

    I --> E: 本核读,其他核无副本
    I --> S: 本核读,其他核有副本
    E --> M: 本核写
    S --> M: 本核写(先发 Invalidate)
    M --> S: 其他核读(回写并共享)
    M --> I: 其他核写(被失效)
    S --> I: 其他核写
    E --> I: 其他核写

关键规则:一个核想写入一个 S 状态的行,必须先向所有持副本的核发 Invalidate 消息,等它们确认失效后才能进入 M。这个「先失效、再写」的往返就是核间通信的成本所在——跨核一次 Invalidate 的往返大约 40–100ns,比 L1 访问贵一个数量级以上。

顺序一致性屏障(第 32 篇的内存屏障)正是构建在 MESI 之上:x86 的强内存模型靠 MESI 的顺序保证即可实现大多数屏障语义,而 ARM 的弱模型需要显式的内存屏障指令。理解 MESI,是理解无锁编程正确性的第一步。

False sharing:无关变量打架,性能掉 10 倍

伪共享(false sharing)指两个核频繁读写逻辑上完全无关、但物理上落在同一缓存行的变量:A 核写 counter_a,B 核写 counter_b,两个变量只差几个字节、同居一个 64 字节行——MESI 不管你写的是不是同一个变量,它只认缓存行。于是每一方写一次,都要 Invalidate 对方的副本;对方下次访问再把它抢回来。数据没有任何共享,缓存行却像乒乓球一样在两核之间来回弹射,每次弹射都是一次跨核往返。

一个最小复现(伪代码):

struct Counters {
    long a;   // 核 0 频繁写
    long b;   // 核 1 频繁写 —— 与 a 同一缓存行!
};
// 4 线程各写自己的 counter,本应线性加速
// 实测吞吐可能不升反降:全部时间花在缓存行弹射上

修复方式是对齐填充:让每个高频写的变量独占一个缓存行。

struct PaddedCounter {
    long value;
    char pad[64 - sizeof(long)];   // 补齐 64 字节
} __attribute__((aligned(64)));

三行代码,把多线程计数器从「扩展性为负」救回「线性加速」。这不是微优化——计数字段、锁、队列头尾指针是伪共享的三大重灾区,它们恰恰长在所有热路径上。

测量:perf c2c 让乒乓现形

伪共享的迷惑性在于它不表现为 cache-miss 总量异常(数据明明在缓存里,只是不在对的核里),普通 perf stat 看不出来。专用工具是 perf c2c(cache-to-cache):它用 PEBS 采样精确记录每个缓存行的读写来自哪些物理地址与哪些核,直接列出 HITM(命中其他核 Modified 行)最多的缓存行——HITM 榜首就是弹射现场。

$ perf c2c record ./your_server
$ perf c2c report --stdio
# 看 "HITM" 列排行,定位被多核争抢的缓存行与源代码行

Java 生态更直接:JMH 自带 -prof perfnorm 能暴露 false sharing 形态;JDK 8 起提供 @Contended 注解,让 JVM 自动给字段加缓存行填充(需 -XX:-RestrictContended)。Netty 的 InternalThreadLocalMap、Disruptor 的 RingBuffer 序列号都手工做了 padding——Disruptor 全体架构的设计动机就是消灭伪共享与假竞争。

三个真实系统怎么防伪共享

Linux 内核的 per-cpu 变量是教科书级方案:每个 CPU 一份独立副本,this_cpu_inc 只碰本核的行,从设计上根除共享。计数类基础设施(网络包计数、内存统计)全走这条路,代价是聚合时要遍历所有 CPU 的副本。

Go 运行时的 mcache:每个 P(处理器)一把本地内存缓存,分配小对象不碰全局 mheap——同样是「每核私有、按需聚合」的思路。Go 官方还专门提供 runtime.CPUProfile 与缓存行对齐的结构体约定(align64),sync.Pool 的实现细节里就有大量填充。

Java 的 @Contended 与 Disruptor:前者是语言级自动填充,后者在 RingBuffer 的游标(cursor)前后各垫 56 字节,保证生产者游标与消费者游标永不同居一行。Disruptor 能做到单机千万级消息/秒,缓存行纪律是隐形的第一功臣。

三者的共性值得提炼:高频写的可变状态,按核/按线程私有化;必须共享的状态,缓存行隔离。这是多核数据结构设计的两条铁律。

与本系列其他章节的勾连

伪共享解释了为什么「加锁慢」不能一概而论:锁竞争的物理本质常常不是 futex 系统调用贵( uncontended 锁根本不陷入内核),而是锁变量的缓存行在核间弹射——先消灭伪共享,再谈锁的粒度。它也是 NUMA(下一篇)的局部前奏:核间弹射发生在同一 socket 内,跨 socket 的弹射还要加上跨节点内存访问的延迟税。而无锁队列为什么必须用内存屏障、为什么 padding + barrier 缺一不可,第 32 篇会把这层讲透。

硬件预取与软件预取:让数据先一步到位

缓存行纪律的另一半是「预取」:既然访存延迟 80 起步,就让数据在 CPU 需要它之前进入缓存。硬件预取器(prefetcher)自动识别规律访问模式:检测到连续跨缓存行的步进访问,就提前搬运后面的行——顺序遍历数组快于链表的根本原因之一(链表的指针跳转破坏规律性,预取器无从预测)。规律性被破坏时(随机访问、间接寻址),可以用软件预取指令显式下注:在循环体提前若干迭代处插入 __builtin_prefetch,把未来要用的行先拉进来。软件预取的调参(提前多远)高度依赖机器与负载,x86 上由 ALIGN/_DISTANCE 宏实践;更现代的替代是把计算重构成可预取的形态——SoA(结构体数组换数组结构体)布局让字段连续存储,硬件预取自然生效,这是 SIMD 与向量化优化的前置工程。观测入口:perf stat 的 l1-dcache-load-misses 与 prefetch 相关事件,miss 率高于百分之五就该审视数据布局。一句话:预取分硬件免费与软件付费两档,但都不如把数据排得规整——布局优化的优先级永远高于预取调参。

一个真实的伪共享事故:监控计数器拖垮网关

把伪共享从演示代码拉到生产现场:某 API 网关扩容后吞吐不升反降,8 核压测吞吐比 4 核还低 15%。火焰图显示 CPU 时间大量消耗在内核的缓存一致性流量上,perf c2c 的 HITM 排行指向网关的统计结构体——一次请求要更新 8 个计数器(总请求数、成功数、各状态码计数、延迟总和),8 个 long 紧挨着住在同一缓存行,8 个 worker 线程高频更新,缓存行在核间弹射到每秒百万次。修复只用了十分钟:给统计结构体的每个字段套 64 字节对齐(alignas(64)),吞吐恢复线性扩展,P99 下降 30%。这个案例的三个教训值得提炼:其一,伪共享的触发条件极其日常——「一个结构体装下所有统计」是最自然的写法,也是最危险的布局;其二,症状有迷惑性(看起来像锁竞争或容量不足),没有 c2c/pcm 这类工具时很容易在错误方向上调优;其三,修复与验证的闭环要量化(修复前后各跑一轮相同压测),「改了就快了」的玄学修复不可信。防御性做法:团队的代码规范里加一条——高频写的计数器、锁、队列指针,一律用对齐宏隔离;code review 时看到「统计聚合结构体」就条件反射地检查布局。伪共享是这个系列里性价比最高的知识:识别它只需一行命令,修复它只需几个字节,放任它的代价是整机的扩展性。

跨核通信的代价谱:从缓存行到跨槽

第 18 篇的核间弹射只是跨核成本的最低档,完整谱系值得排一遍,因为它决定了并发架构的「通信预算」。L1 命中 1ns(同核);同槽跨核(共享 L3)的一次缓存行转移约 30-60ns;跨槽(NUMA 节点间)的转移 100-200ns;跨核的锁竞争往返(含 futex 系统调用的冤枉路)微秒级;跨机器的网络往返微秒到毫秒级。谱系的工程含义:算法复杂度上的优化在跨核成本面前可能不划算——把 O(log n) 的全局红黑树换成 O(1) 的每核本地结构加聚合,哪怕本地结构本身慢一点,省下的跨核往返也是净赚;Linux 内核的 slub 从全局锁演进到 per-cpu 缓存(第 3 篇)、调度器从全局队列演进到每核运行队列,都是这条预算表驱动的架构决策。应用层的对应:无锁队列的「单生产者单消费者」变体之所以比多生产者快一个量级,本质是它把通信压缩到了一对核之间的两次缓存行转移;而全局队列的 CAS 风暴让缓存行在 N 个核间轮转,成本随核数线性上升。设计并发结构时先问「一次操作需要几次跨核往返」,再谈算法复杂度——多核时代的性能模型里,往返次数是比指令数更硬的通货。

小结

CPU 以 64 字节缓存行为单位搬运与同步数据,MESI 协议用「写前失效」保证一致,代价是核间 40–100ns 的往返。伪共享让逻辑无关的变量因同居一行而互相弹射,是并发程序扩展性为负的头号隐形原因;perf c2c 的 HITM 排行是它的照妖镜,对齐填充是它的特效药。Linux per-cpu、Go mcache、Disruptor padding 三个案例共享同一设计哲学:高频写的状态私有化,必须共享的状态缓存行隔离。下一篇进入更大的空间尺度——NUMA 与巨页,看跨节点的延迟税与 TLB 的解药。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?