Rust in Everything
基于 crossbeam-utils 0.8.23

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

cache_padded.rs:153:

#[repr(C)]
pub struct CachePadded<T> {
    value: T,
}

对齐值写在它上面的 cfg_attr 里(85–151 行),按 target_arch 分五档:

对齐架构源码注释给的依据
128x86_64、aarch64、arm64ec、powerpc64x86_64:从 Sandy Bridge 起,空间预取器一次拉相邻的两条 64 字节行(引 Intel 优化手册、folly 的 Align.h);aarch64 / arm64ec:big.LITTLE 里「大核」的行长是 128;powerpc64:行长 128(引 Go 和 Linux 源码)
32arm、mips、mips32r6、mips64、mips64r6、sparc、hexagon引 Go 的 internal/cpu 和 Linux 的 asm/cache.h
16m68kLinux 的 asm/cache.h
256s390xGo 和 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 倍(AtomicU64 8 字节 → 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 的提交历史:

提交日期改了什么
e50e7b302019-02-15x86-64 改用 128
500822642020-05-12aarch64 翻倍到 128
1e16fc042021-01-03按 Go 的 CacheLinePadSize 整体更新各架构
a964ffd82023-03-04riscv32 / sparc / hexagon 改 32,m68k 改 16
be4053ac2023-09-10riscv32、riscv64 改回 64
d0d0a80b2024-12-08arm64ec 加入 128 一档
d3e1e3642026-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.23std 1.99.1 mpmc::utils
128x86_64、aarch64、arm64ec、powerpc64x86_64、aarch64、powerpc64
32arm、mips 系列、sparc、hexagonarm、mips 系列
16m68k无(m68k 落到 64)
256s390xs390x
repr(C)有无
newconst 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 字节,或各包一层 CachePadded101–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 字节才没有伪共享。

💬 关联讨论

发起讨论
加载中...