@debasishg:我关于Rust底层系统设计系列的第一部分现已发布 - 这部分涵盖:• 如何根据谁接触什么来布局共享的Rust结构体…
摘要
关于Rust底层系统设计系列的第一部分介绍了缓存感知的数据布局技术,包括字段分区以避免伪共享,重点涉及多线程结构体和128字节规则,并以SPSC环形缓冲区为例。
查看缓存全文
缓存时间: 2026/06/29 06:24
我的《Rust 底层系统设计》系列第 1 部分现已上线 —— 本部分涵盖:
• 如何根据“谁触摸什么”来布局共享的 Rust 结构体 • 按写入者和频率对字段进行分区,然后 • 填充跨核心字段以避免伪共享和预取器引起的争用
Rust 中缓存感知的数据布局:字段分区、伪共享与 128 字节规则 - https://debasishg.github.io/blog/part1-cache-conscious-data-layout-in-rust/
Rust 中缓存感知的数据布局:字段分区、伪共享与 128 字节规则
来源:https://debasishg.github.io/blog/part1-cache-conscious-data-layout-in-rust/
缓存感知的数据布局:字段分区、伪共享与 128 字节规则
《Rust 底层系统设计》 系列的第 1 部分 —— 一个关于编写高吞吐、低延迟系统代码的系列,以单生产者/单消费者(SPSC)环形缓冲区作为贯穿示例。
第 0 部分 —— 架构分解 (https://debasishg.github.io/blog/part0-architectural-decomposition-remove-contention-by-design/) 做出了最具杠杆作用的决策(从结构上消除争用,让每个写入者拥有自己的环形缓冲区),并包含完整的系列索引和指导原则。
本文从微观层面开始:给定这样一个 SPSC 环形缓冲区,它在内存中应该如何布局?这些模式并非环形缓冲区独有 —— 它们适用于任何被多个核心访问的结构体。
没人告诉你的关于多线程结构体的事
当你编写单线程数据结构时,唯一重要的布局问题是“它是否适合缓存”。但当你编写多线程数据结构时,第二个相关且略微微妙的问题出现了:
对于每个字段,哪个核心触摸它,以及触摸的频率?
搞错这一点会让你付出代价,而且这种代价不会以热行(hot line)的形式被性能分析器标记出来。两个核心向不同字段写入,但恰好共享同一个 64 字节缓存行,它们会通过硬件一致性协议悄悄地将彼此串行化 —— 这种病理现象称为伪共享。代码看起来是无锁的,但基准测试结果却相反。
本文旨在有意识地设计布局。它分为两部分:
- 字段分区 —— 根据
(写入者, 频率)对字段进行分组,这样一个核心的热写入不会驱逐另一个核心的热工作集。 - 对齐与填充 —— 使分区成为现实的机制:为什么
#[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 只是底层缓冲区的可插拔分配器,如果你愿意可以忽略它 —— 重要的是字段的顺序和分组:
#[repr(C)]
pub struct Ring<A: Allocator> {
// 生产者热点
tail: CacheAligned<AtomicU64>,
cached_head: CacheAligned<UnsafeCell<u64>>,
// 消费者热点
head: CacheAligned<AtomicU64>,
cached_tail: CacheAligned<UnsafeCell<u64>>,
// 冷区
closed: AtomicBool,
metrics: Metrics,
config: Config,
buffer: UnsafeCell<Vec<u8, 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 的工作。
一个自我回报的单行辅助类型
/// 将值对齐到 128 字节边界,这样跨核心字段就不会共享缓存行。
/// 选择 128(而非 64)是一种目标策略:它也为 CPU 上的相邻行/空间预取效应留出空间,
/// 在这些 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<T> 也会占用完整的 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 实际看到的布局
结合分区和对齐,构造过程很普通 —— 布局工作已经由类型完成了:
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 基准测试后发现损害了吞吐量 —— 提示最终被移除。
诚实的规则:软件预取仅在非线性访问模式中才有价值 —— 图遍历、哈希表探测序列、指针追逐 —— 在这些情况下硬件预取器无法预测下一个地址。即使如此,只有在基于 profile 的实验证明它对你的工作负载有帮助时才添加它们。对于任何步长为 1 的访问,让预取器自己做自己的工作。
有意选择适合某个缓存级别的容量
环形缓冲区的缓冲区有它自己的缓存故事,与游标分开。容量并不能保证驻留在特定的缓存级别中,但它确实设定了你要求缓存层级携带的工作集预算。有意识地选择它,而不是随便输入一个整数然后让硬件发现权衡:
- 大小适合留在 L1 附近。 对于 8 字节元素,4096 个槽是 32 KiB。这可以是具有 32 KiB 或更大 L1D 的核心上缓存驻留环形的合理起点,但它不会为 32 KiB L1 上的其他热数据留下空间。L1D 大小各不相同,容量上的适配不能保证零 L2/L3 缺失 —— 冲突缺失、预取行为、一致性流量以及同一核心上的其他热数据仍然都很重要。用硬件计数器确认,而不是算术。
- 大小适合吸收突发。 将大小定为 256K 槽(对于 8 字节元素为 2 MiB)会故意不再假装缓冲区是 L1 驻留的,并接受较低的缓存或 LLC 延迟,以换取在无需反压的情况下吸收更大的突发。
两者都是合理的。不合理的是让“一个漂亮的整数,比如一百万”悄悄进入你的配置,并意外地决定了你的热路径必须携带的工作集。
这可以推广到哪里
这些都不是环形缓冲区特有的。只要一个结构体在核心间共享,“根据 (写入者, 频率) 进行分区,然后填充跨核心字段”的模式就会出现:
- SPSC 队列的
tail/head游标,以及 MPSC 队列中有争用的游标(填充可以防止伪共享;但不能消除共享 tail 的争用), - 分片指标中的每线程计数器(每个核心一个
AtomicU64,读取时求和), - 更多……
相似文章
Rust 零拷贝页面:我是如何停止焦虑并爱上生命周期的
# Rust 零拷贝页面:我是如何停止焦虑并爱上生命周期的 来源:[https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html](https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html) *你可以在[这里](https://github.com/redixhumayun/simpledb/)找到该项目的源代码* 零拷贝是一种旨在消除内核与用户空间缓冲区之间 CPU 数据复制的技术,尤其在数据处理等高吞吐量应用中极具价值。
安全 Rust 的边界
TokioConf 2026 的一篇演讲/博客文章探讨了如何通过为复杂指针结构实现追踪式垃圾回收,将安全 Rust 推向极限,并分享处理循环引用与原始指针 GC 设计的技巧。
安全变得简单 第1部分:单一所有权(并非)可选
本文介绍了一种基于线性类型和抽象解释的内存安全新方法,旨在比Rust更符合人机工程学原理地消除诸如释放后使用和内存泄漏等常见错误。
不会编译的数据竞争
本文解释了作者如何利用ruxe库中的类型级不相交技术,教会Rust的类型系统拒绝可能导致数据竞争的并行reducer管道。
Rust语言的性能
本次演讲分析了Rust相较于C++的性能优势与劣势,提供了基准测试和最佳实践。附有幻灯片和阅读材料。