缓存时间:
2026/07/10 06:21
# 缓存感知数据布局:字段分区、伪共享与128字节规则 来源:https://debasishg.github.io/blog/part1-cache-conscious-data-layout-in-rust/
## 缓存感知数据布局:字段分区、伪共享与128字节规则
第1部分,属于**低层系统设计在Rust中的实践**系列——该系列以单生产者/单消费者(SPSC)环形缓冲区为运行示例,讲解如何编写高吞吐量、低延迟的系统代码。
- 第0部分 – *架构分解*(https://debasishg.github.io/blog/part0-architectural-decomposition-remove-contention-by-design/)做出了最高杠杆率的决策(通过架构设计消除竞争,让每个写入者拥有自己的环形缓冲区),并包含完整的**系列索引**和指导原则。
- 本贴开始微观层面的工作:给定一个这样的SPSC环形缓冲区,如何在内存中布局?这些模式并非环形缓冲区独有——它们适用于任何被多个核心访问的结构。
---
## 关于多线程结构体,没人告诉你的那件事
当你编写一个单线程数据结构时,唯一重要的布局问题是“它是否适合缓存”。但当你编写一个**多线程**结构时,会出现第二个既相关又微妙的问题:
> 对于每个字段,**哪个核心访问它,以及访问频率如何?**
搞错这一点会让你付出代价,而且没有性能分析器会将其标记为某个热点行。两个核心写入**不同**的字段,但碰巧共享同一个64字节缓存行,会通过硬件一致性协议悄悄地将彼此串行化——这种现象称为**伪共享**。代码看起来是无锁的,但基准测试却显示并非如此。
本贴讨论如何有意地设计布局。它分为两部分:
1. **字段分区** – 按`(写入所有者, 频率)`对字段进行分组,使得一个核心的热写入不会驱逐另一个核心的热工作集。
2. **对齐与填充** – 使分区成为现实的机制:为什么`#[repr(C)]`是承重的,为什么神奇数字通常是128而不是64,以及为什么**添加**预取提示反而会让情况更糟。
运行示例是一个单生产者/单消费者(SPSC)环形缓冲区。一个核心(生产者)追加数据,另一个核心(消费者)取出数据。它们通过两个单调递增的游标协调——`tail`(生产者下次写入的位置)和`head`(消费者下次读取的位置)。虽然下面的讨论和设计原则足够通用,但你可以参考这个[环形缓冲区](https://github.com/debasishg/ringmpsc-rs/tree/main/crates/ringmpsc)实现,它遵循了这些指导原则。
---
## 第1部分 – 字段分区:基于“谁访问什么”的设计
缓存行——在x86-64和许多AArch64核心上通常为64字节——是核心间通信的货币单位。一致性流量是按**行**计算的,而不是按字节或字段。因此,任何共享结构的第一个设计动作就是按照访问模式对其字段进行分区:
| 区域 | 字段 | 写入所有者 | 跨核心访问 |
|------|------|------------|------------|
| 生产者热区 | `tail`, `cached_head` | 生产者 | 消费者读取`tail` |
| 消费者热区 | `head`, `cached_tail` | 消费者 | 生产者读取`head` |
| 冷区 | `closed`, `config`, `metrics` | 生命周期/可观测性 | 极少,非热路径轮询 |
“生产者热区”**并不**意味着“仅生产者”。生产者负责写入`tail`,但消费者在其缓存视图耗尽时仍然会读取`tail`。重要的区别是**写入所有权**:生产者是持续使该行保持独占的核心,因此该写入字段附近的字段必须精心选择。
首先,定义一些术语,因为文章其余部分会用到它们。**热路径**是几乎每次操作都会运行的代码——在这里,每个核心每秒运行数百万次的发送和接收循环。**热字段**是这些循环访问的字段,比如`tail`、`head`以及两个游标缓存。**冷路径**与之相反,是很少运行的代码——构造、关闭、偶尔的指标读取——而**冷字段**是只有冷路径访问的字段,比如`config`和`metrics`。“热”和“冷”是针对**访问频率**而言的,这正是上表中各区域排序的依据。
于是规则很简单:**将每个区域放在自己的一组缓存行上。**一个核心写入的热字段不能与另一个核心频繁读取或写入的热字段共享一行——生产者对`tail`的存储不应使消费者持有的`head`或`cached_tail`所在的行失效,反之亦然。冷字段由于在热路径上没有竞争,可以紧密地打包在末尾。
下面是一个在其声明中直接编码了这些区域的环形缓冲区。类型参数`A`只是一个可插拔的底层缓冲区分配器,如果你愿意可以忽略它——重要的是字段的**顺序和分组**:
```rust
#[repr(C)]
pub struct Ring<A> {
// 生产者热区
tail: CacheAligned<AtomicU64>,
cached_head: CacheAligned<UnsafeCell<u64>>,
// 消费者热区
head: CacheAligned<AtomicU64>,
cached_tail: CacheAligned<UnsafeCell<u64>>,
// 冷区
closed: AtomicBool,
metrics: Metrics,
config: Config,
buffer: UnsafeCell<Buffer<A>>,
}
```
这里有几个值得仔细阅读的地方,因为每一个都是有意为之的选择,而非风格上的偶然。
**两个`cached_*`字段位于热区,而非冷区。**生产者想知道“是否有空间?”必须将`tail`与`head`进行比较。但`head`是由**另一个**核心写入的,因此直接读取它可能导致跨核心一致性缺失——数十到数百个周期。作为替代,生产者维护`cached_head`:一个单写入者、生产者拥有的快照,记录消费者**上次我们查看时**的位置。由于对`head`的陈旧视图只会*低估*可用空间(绝不会高估),快速路径上始终可以信任这个缓存,并且只有在缓存显示没有空间时,才会触发对真实`head`的昂贵的`Acquire`读取。这个快照在**每次**发送时都会被访问,因此它属于生产者热区——并且它特意使用普通的`UnsafeCell`而非原子类型,正是因为只有一个核心会写入它。消费者有镜像情况:`cached_tail`使其避免读取生产者拥有的`tail`,直到本地快照耗尽。
**冷字段特意共享缓存行。**`closed`、`metrics`和`config`紧密打包在一起,它们之间没有填充。这不是懒惰——这正是目的所在。分区的目标不是对齐所有内容;而是只对齐那些在核心之间传递的内容。给冷字段填充空间只会浪费一个本可以用来保持热数据驻留的缓存行。如果某个生命周期标志在每次发送或接收时都被轮询,那它就不再是冷字段;将其提升到自己的热区或生命周期区域,而不是藏在“关闭”一词后面。
### 为什么`#[repr(C)]`在做真正的工作
注意结构体上的`#[repr(C)]`。它不是装饰性的,移除它会暗中破坏整个布局策略。默认情况下,Rust使用`repr(Rust)`,而[Rust参考文档明确说明](https://doc.rust-lang.org/reference/type-layout.html#the-rust-representation)`repr(Rust)`仅保证字段的*对齐*,字段不重叠,并且聚合体有足够的对齐。它明确**不**保证字段按声明顺序排列——编译器可以自由重排(通常是为了最小化填充)。对于普通结构体,这是一个特性。但对于一个性能正确性依赖于`tail`和`head`位于**不同**区域的结构体,重排可能会将你精心分离的区域重新合并到共享行上。
`#[repr(C)]`将字段固定为声明顺序,因此你编写的区域布局就是你得到的布局。
有一个很容易被忽略的微妙之处:**外部结构体的`repr(C)`不会递归地冻结嵌套的`repr(Rust)`字段的布局。**它固定了`Ring`自身字段的顺序,但比如说`Metrics`或`Config`的内部顺序仍然是`repr(Rust)`,除非这些类型也带有自己的`repr(C)`。如果嵌套结构体有自己的热/冷分区需要考虑,也要给它加上`repr(C)`。这里不需要,因为`Metrics`和`Config`完全是冷字段。
---
## 第2部分 – 对齐与伪共享:让分区成为现实
分区是*意图*,对齐是*机制*。声明`tail`和`head`属于不同区域,如果字节实际上没有落在不同的缓存行上,那就毫无意义。这就是`CacheAligned`的职责。
### 一行就值回票价的辅助类型
```rust
/// 将值对齐到128字节边界,使得跨核心字段不会共享缓存行。
/// 选择128(而非64)是一个目标策略:它也为某些CPU上的邻行/空间预取效应留出空间。
#[repr(C, align(128))]
struct CacheAligned<T> {
value: T,
}
impl<T> CacheAligned<T> {
const fn new(value: T) -> Self {
Self { value }
}
}
impl<T> std::ops::Deref for CacheAligned<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.value
}
}
```
就这些——一个`#[repr(C, align(128))]`的新类型,带有`Deref`,在调用点透明(`self.tail.load(...)`直接可用)。不需要外部库依赖。因为`Ring`是`repr(C)`,编译器按声明顺序布局字段,并插入任何必要的填充以满足每个字段的对齐。由于Rust类型的大小是其对齐的倍数,每个小的`CacheAligned`也会消耗一个完整的128字节槽位。没有两个热字段可以共享一行,并且——关键的是——它们也不能与后面的*冷*字段共享一行。
### 为什么是128字节,而缓存行是64字节?
这是让人困惑的地方。如果缓存行是64字节,为什么填充到128字节?因为严格的线级分离仍然可能过于乐观。一些CPU具有邻行或空间预取行为:当你访问一个64字节行时,核心可能会推测性地拉入它的邻居。如果生产者热数据位于紧邻消费者热数据的行上,预取器可能会将两者拖入彼此的缓存中,即使严格来说它们从未共享同一行。结果是**预取器引发的伪共享**:这些行在技术上分离,但竞争是真实的,线粒度工具可能无法直接指出。你看到的是吞吐量低于算法预测。
对于小的游标字段,将每个热字段对齐到128字节使其起始于偶数64字节行边界,因此邻行预取通常会拉入填充而非另一个核心的热数据。将确切宽度视为**目标特定的策略,而非通用常量**。Crossbeam当前的`CachePadded`(https://docs.rs/crossbeam-utils/latest/crossbeam_utils/struct.CachePadded.html)策略是:
- 在x86-64、AArch64和powerpc64上为**128字节**,
- 在s390x上为**256字节**,
- 在arm、mips、mips64、sparc和hexagon上为**32字节**,
- 在m68k上为**16字节**,
- 其他所有平台上为**64字节**。
Crossbeam也记录了这些是合理的猜测,并非保证匹配你运行机器的物理缓存行。一个固定的`align(128)`的本地`CacheAligned`在你特别想要*固定*该策略时是合适的(例如,你已经对你的目标进行了基准测试并希望冻结它)。无论哪种方式:**通过基准测试验证**,不要相信任何单一数字——包括128。
### 综合起来:CPU实际看到的布局
结合了区域和对齐后,构造过程并不特别——布局工作已经由类型完成:
```rust
Ring {
tail: CacheAligned::new(AtomicU64::new(0)),
cached_head: CacheAligned::new(UnsafeCell::new(0)),
head: CacheAligned::new(AtomicU64::new(0)),
cached_tail: CacheAligned::new(UnsafeCell::new(0)),
closed: AtomicBool::new(false),
// metrics, config, buffer ...
}
```
生产者核心写入`tail`行并使用`cached_head`行。消费者核心写入`head`行并使用`cached_tail`行。已发布的游标仍然会产生不可避免的跨核心通信——消费者有时必须观察`tail`,生产者有时必须观察`head`——但这些失效不再连带拖拽无关的热字段。这就是回报,它每个热字段花费大约一个128字节槽位:对于一个被两个核心高频访问的结构来说是个好交易,而你永远不会为冷字段做这样的开销。
---
## 两个反直觉的推论
一旦你从缓存行和预取器的角度思考,两个“显而易见”的性能经验法则就会被证明是错误的。
### 不要随意添加软件预取提示
一个常见的本能是在了解预取器存在后“帮助”它:在热循环中插入`_mm_prefetch`(或Rust的`core::intrinsics::prefetch_*`)以提前拉入下一个槽位。对于环形缓冲区——即**步长为1的线性访问**——这几乎总是**损失**。现代L1/L2硬件预取器在最初2–3次访问内就能检测到线性步长,并自动在CPU之前流式拉入缓存行。手动软件预取不会增加任何东西;它反而会**竞争**——消耗指令发出槽位和内存带宽,而这些正是需求获取实际需要的,同时硬件已经打算拉入那些行。这是一个我见过不止一次被投入生产的错误:一个热循环注入了手动预取提示,经过A/B基准测试后发现**损害**吞吐量——于是提示又被移除。
诚实的规则:软件预取只在**非线性**访问模式中才有价值——图遍历、哈希表探测序列、指针追逐——在这些模式中硬件预取器**无法**预测下一个地址。即使如此,只有在基于性能分析的实验证明它们对你的工作负载有帮助时才添加。对于任何步长为1的访问,别妨碍预取器。
### 选择一个恰好适合某级缓存的大小——刻意为之
环形缓冲区的**缓冲区**本身有它自己的缓存故事,与游标无关。容量并不能保证在某级缓存中的驻留,但它确实设定了你要求缓存层级携带的工作集预算。有意识地选择它,而不是输入一个整数然后让硬件去发现取舍:
- **大小设置成接近L1。** 对于8字节元素,4096个槽位是32 KiB。对于L1D为32 KiB或更大的核心,这可以作为*缓存驻留*环形缓冲区的一个合理起点,但在32 KiB的L1上不会给其他热数据留下空间。L1D大小各异,并且容量上的匹配**并不**保证零L2/L3缺失——冲突缺失、预取行为、一致性流量以及同一核心上的其他热数据仍然重要。用硬件计数器确认,而不是算术。
- **大小设置成能吸收突发。** 设置成256K个槽位(对于8字节元素为2 MiB)刻意放弃缓冲区是L1驻留的假设,接受较低缓存或LLC延迟,以换取吸收更大突发而无需背压。
两种都是合法的。但让“像一百万这样的整数”偷偷溜进配置并*意外*决定热路径必须携带的工作集,这是不合法的。
---
## 这些模式适用于哪些场景
所有这些都不是环形缓冲区特有的。只要一个结构在核心间共享,“按`(写入所有者, 频率)`分区,然后填充跨核心字段”的模式就会出现:
- SPSC队列的`tail`/`head`游标,以及MPSC队列中有竞争的游标(填充可以防止伪共享,但不会消除共享的尾部竞争),
- 分片指标中**每线程计数器**(每个核心一个`AtomicU64`,读取时求和),
- EBR / 危险指针内存回收中的**每核心纪元计数器**,
- 每CPU**调度器运行队列**,
- 无锁哈希表中的**调整大小纪元计数器**。
每种情况下方法都一样:
1. 对于每个字段,明确写入所有者、读取者和频率。
2. 将字段分组到热区(每个写入所有者一个,加上任何不可避免的多写入者原子变量的隔离区)和一个冷区。
3. 使用`#[repr(C)]`锁定顺序。
4.