CachePadded
主线位置:横线。它本身不在任何一条调用链上,但三条主线都踩着它:一条消息里 array channel 的
head/tail,一个任务里 deque 的Inner和buffer,一块内存的回收里全局纪元和每个参与者的本地纪元,都包在CachePadded里。
flowchart LR
subgraph bad["不填充:两个计数器同一条缓存行"]
a1["线程 A 写 a"] --> line1["缓存行 0..128:a | b"]
b1["线程 B 写 b"] --> line1
end
subgraph good["CachePadded:各占一条"]
a2["线程 A 写 a"] --> la["缓存行 0..128:a + 填充"]
b2["线程 B 写 b"] --> lb["缓存行 128..256:b + 填充"]
end
两个线程各写各的变量,本来互不相干。但缓存一致性协议按缓存行管理所有权:一个核写某条缓存行之前,要先让其他核手里的副本失效。两个变量落在同一条缓存行里,A 写 a 会把 B 手里的那条行作废,B 写 b 又把 A 的作废,这条行就在两个核之间来回搬。这叫伪共享(false sharing)。CachePadded<T> 的做法很直接:把 T 对齐到一条缓存行的长度,大小补成它的整数倍,于是两个 CachePadded 不可能落在同一条行里。
整个文件 223 行,类型本身只有一个字段,篇幅几乎都在一串 cfg_attr 上。
一个字段,一串 cfg_attr
#[repr(C)]
pub struct CachePadded<T> {
value: T,
}
对齐值写在它上面的 cfg_attr 里(85–151 行),按 target_arch 分五档:
| 对齐 | 架构 | 源码注释给的依据 |
|---|---|---|
| 128 | x86_64、aarch64、arm64ec、powerpc64 | x86_64:从 Sandy Bridge 起,空间预取器一次拉相邻的两条 64 字节行(引 Intel 优化手册、folly 的 Align.h);aarch64 / arm64ec:big.LITTLE 里「大核」的行长是 128;powerpc64:行长 128(引 Go 和 Linux 源码) |
| 32 | arm、mips、mips32r6、mips64、mips64r6、sparc、hexagon | 引 Go 的 internal/cpu 和 Linux 的 asm/cache.h |
| 16 | m68k | Linux 的 asm/cache.h |
| 256 | s390x | Go 和 Linux |
| 64 | 其余全部(x86、wasm、riscv、sparc64 等) | 注释点名 x86、wasm、riscv、sparc64 是 64,其余「假定」为 64 |
文档里说得很清楚:N「只是一个合理的猜测,不保证和运行程序的机器的实际行长一致」。它是编译期按目标架构定死的,不会在运行时探测。
由此有两条直接推论:
- 大小是 N 的整数倍,对齐取 N 和
align_of::<T>()的较大者。CachePadded<u8>在 aarch64 上是 128 字节;CachePadded<[u8; 130]>是 256 字节。一个小计数器包上它,内存放大 16 倍(AtomicU648 字节 → 128 字节)。 - 它只保证「自己」不和别人共享缓存行。 两个相邻字段只要有一个没包,另一个包了,就已经隔开了;反过来,
CachePadded<(AtomicU64, AtomicU64)>里那两个计数器仍然挤在同一条行上。
crossbeam 自己在这些地方用它:array / list channel 和 ArrayQueue / SegQueue 的 head、tail;deque 的 Inner(整个包在 Arc<CachePadded<Inner<T>>> 里)、buffer 字段和 Injector 的 head / tail;epoch 的全局纪元、每个 Local 的本地纪元、内部 MS 队列的 head / tail;AtomicCell 的 67 把全局锁和 ShardedLock 的 8 个分片;skiplist 的 hot_data。
这些数字是怎么来的
对齐表不是一次定下的。cache_padded.rs 的提交历史:
| 提交 | 日期 | 改了什么 |
|---|---|---|
e50e7b30 | 2019-02-15 | x86-64 改用 128 |
50082264 | 2020-05-12 | aarch64 翻倍到 128 |
1e16fc04 | 2021-01-03 | 按 Go 的 CacheLinePadSize 整体更新各架构 |
a964ffd8 | 2023-03-04 | riscv32 / sparc / hexagon 改 32,m68k 改 16 |
be4053ac | 2023-09-10 | riscv32、riscv64 改回 64 |
d0d0a80b | 2024-12-08 | arm64ec 加入 128 一档 |
d3e1e364 | 2026-07-06 | 加 #[repr(C)](#1270,0.8.22) |
「x86_64 取 128」和「aarch64 取 128」的理由不一样。x86_64 的缓存行确实是 64 字节,取 128 是为了防相邻行预取:核 A 写第 0 行时,预取器可能顺手把第 1 行也拉进来,第 1 行上的变量就被牵连。aarch64 的理由是行长本身:同一颗 SoC 里大小核行长不同,按大的取。
repr(C):0.8.22 起的布局保证
0.8.22 之前,CachePadded<T> 是默认的 Rust 布局。只有一个字段时编译器实际上也把它放在偏移 0,但这不是保证。#1270 加上 #[repr(C)],文档随之写明(第 34 行):指向 CachePadded<T> 的指针和指向内部 T 的指针地址相同。于是可以把 *const CachePadded<T> 直接 cast 成 *const T,反过来也行(只要原来的 T 确实按 CachePadded 的要求对齐、后面有足够的空间)。
其余的 API 很少:const fn new、into_inner、Deref / DerefMut、From<T>,派生 Clone、Copy、Default、Hash、PartialEq、Eq,手写 Debug 和 Display(0.8.20 加的 Display,#1097)。Send / Sync 都转发给 T。
std 里的一份删减版
标准库的 std::sync::mpsc 从 1.67.0 起换成了从 crossbeam-channel 移植的实现(06 和 std 对照),CachePadded 也跟着复制进了 library/std/src/sync/mpmc/utils.rs。rustc 1.99.1 自带的这份和 crossbeam-utils 0.8.23 比:
| crossbeam-utils 0.8.23 | std 1.99.1 mpmc::utils | |
|---|---|---|
| 128 | x86_64、aarch64、arm64ec、powerpc64 | x86_64、aarch64、powerpc64 |
| 32 | arm、mips 系列、sparc、hexagon | arm、mips 系列 |
| 16 | m68k | 无(m68k 落到 64) |
| 256 | s390x | s390x |
repr(C) | 有 | 无 |
new | const fn | 普通 fn |
| 其他 | into_inner、From、Debug、Display 等 | 只有 new、Deref、DerefMut |
std 那份是内部类型,只给 channel 自己的 head / tail 用,没有跟进 crossbeam 后来的几次调整。
在别的卷里见过它
- tokio 的任务内存布局:tokio 给每个任务的
Cell加的repr(align(N)),各架构取值的注释写明是从 crossbeam-utils 的cache_padded.rs抄来的。 - tokio 多线程调度器:
Shared::inject和Shared::idle到 1.54.0 才包上CachePadded(tokio #8622)。 - tokio mpsc:
Chan里的tx和rx_waker各自包一层。 - rayon-core 1.13.0 的
sleep/mod.rs直接用 crossbeam-utils 的CachePadded包每个 worker 的睡眠状态(worker_sleep_states: Vec<CachePadded<WorkerSleepState>>),rayon 卷会再讲。
实验 E1:伪共享有多贵,距离多远才够
断言:两个线程各写各的计数器,计数器挨得近时比各包一层 CachePadded 慢得多;单独看距离,M2 Max 上要隔开 128 字节才没有伪共享。
方法(exp-crossbeam/false-sharing):两个线程各对自己的 AtomicU64 做 5000 万次 fetch_add(1, Relaxed),量总耗时。两个计数器放在一块 256 字节对齐的缓冲区里,第一个在偏移 0,第二个在偏移 D,扫 D = 8 到 256;另测两个 CachePadded<AtomicU64> 字段,以及反向对照「两个线程打同一个计数器」。
#[repr(C, align(256))]
struct Buf {
words: [AtomicU64; 64],
}
fn run(a: &AtomicU64, b: &AtomicU64) -> f64 {
let t = Instant::now();
thread::scope(|s| {
s.spawn(|| for _ in 0..N { black_box(a.fetch_add(1, Ordering::Relaxed)); });
s.spawn(|| for _ in 0..N { black_box(b.fetch_add(1, Ordering::Relaxed)); });
});
t.elapsed().as_secs_f64() * 1e3
}
// run(&buf.words[0], &buf.words[d / 8]) for d in [8, 32, 56, 64, 96, 120, 128, 192, 256]
结果:2026-11-01,rustc 1.99.1,--release,每种布局 5 轮。开始时 1 分钟负载 2.92(另有别的实验在机器上断续运行,波动主要来自这里):
CachePadded<AtomicU64>: size=128 align=128; field b at offset 128
distance 8 B median 678 ms runs [678, 773, 807, 669, 645]
distance 32 B median 675 ms runs [675, 810, 827, 659, 642]
distance 56 B median 682 ms runs [769, 836, 682, 652, 645]
distance 64 B median 130 ms runs [143, 187, 125, 126, 130]
distance 96 B median 157 ms runs [208, 207, 121, 157, 125]
distance 120 B median 163 ms runs [215, 163, 125, 180, 129]
distance 128 B median 106 ms runs [104, 102, 113, 106, 112]
distance 192 B median 106 ms runs [104, 103, 113, 106, 112]
distance 256 B median 105 ms runs [105, 102, 113, 104, 110]
CachePadded x2 median 106 ms runs [105, 103, 113, 106, 112]
same counter median 706 ms runs [762, 810, 652, 706, 670]
隔 10 分钟再跑一轮(开始时负载 2.99),台阶位置不变:距离 8–56 字节 733–745 ms,64–120 字节 135–158 ms,≥ 128 字节和 CachePadded 都是 101 ms,同一个计数器 720 ms。
数据分成三档,台阶在 64 和 128:
| 两个计数器的距离 | 中位数 | 相对 CachePadded |
|---|---|---|
| 8–56 字节(同一个 64 字节块) | 675–745 ms | 约 6.4–7.4 倍 |
| 64–120 字节(同一条 128 字节行、不同的 64 字节半行) | 130–163 ms | 慢 23%–56% |
≥ 128 字节,或各包一层 CachePadded | 101–106 ms | 基准 |
- 反向验证成立:两个线程打同一个计数器(真共享)是 706 / 720 ms,和距离 8 字节的伪共享同一量级。说明「挨着放」的代价和真的争同一个变量差不多。
- 只隔开 64 字节不够。 按 x86 的直觉对齐到 64,在 M2 Max 上仍然比各占一条 128 字节行慢两到五成;
CachePadded在 aarch64 上取 128,正好落在第三档。这也回答了规划阶段留下的疑问(当时测到「隔 64 字节比 128 字节慢约 35%」,怀疑是相邻行预取):hw.cachelinesize报 128,隔 64 字节的两个计数器本来就在同一条缓存行里。 - 至于为什么同一条行里「同半行」比「不同半行」又贵 4–5 倍,从这组数据看不出硬件原因。推测 M2 的一致性协议或存储缓冲在 64 字节粒度上还有区分,未核实。
小结
CachePadded<T>只有一个字段,靠cfg_attr(target_arch = ..., repr(align(N)))按架构选 N:x86_64 / aarch64 / arm64ec / powerpc64 是 128,arm / mips / sparc / hexagon 是 32,m68k 16,s390x 256,其余 64;编译期定死,不探测。- x86_64 取 128 是防相邻行预取,aarch64 取 128 是大核行长;数值大多引自 Go 的
internal/cpu和 Linux 源码。 - 0.8.22 起
repr(C),指针可以和&T互相 cast。 - std 内部的 mpmc 带着一份删减版:少了 arm64ec、sparc、hexagon、m68k 的特判,没有
repr(C)。 - 代价是内存:一个 8 字节的原子变量包上它要占 128 字节。
- M2 Max 实测(E1):两个计数器在同一个 64 字节块里慢 6–7 倍,同一条 128 字节行的不同半行慢两到五成,隔开 128 字节才没有伪共享。