AtomicCell
主线位置:横线。
AtomicCell不在三条主线的调用链上;channel 里唯一的使用者是tickflavor 的delivery_time: AtomicCell<Instant>(timers)。本页讲它的两条路径各用什么内存序,结论汇总到 本库里的内存序。
flowchart TB
op["AtomicCell<T>::load / store / swap / compare_exchange"] --> chk{"can_transmute:size 相等且 align ≥ 原子类型的 align"}
chk -->|"()"| unit["AtomicUnit:什么都不做"]
chk -->|"1 / 2 / 4 / 8 字节"| native["AtomicU8..AtomicU64:Acquire 读、Release 写、AcqRel 改"]
chk -->|其余| lock["LOCKS[addr % 67]:全局 seqlock"]
AtomicCell<T> 是能跨线程共享的 Cell<T>:任何 T 都能放,load 要求 T: Copy,compare_exchange 要求 T: Copy + Eq。同一个 API 背后有两条路:T 恰好能当成某个原生原子整数看待,就用原子指令;否则用全局锁。调用方看不出走的是哪条,除了一个 const fn is_lock_free()。
判断走哪条路:一个宏,五次比较
atomic! 宏(atomic_cell.rs:353)把同一段操作按候选类型展开五遍,第一个对得上的执行原子版本,都对不上执行锁版本:
loop {
atomic!(@check, $t, AtomicUnit, $a, $atomic_op);
atomic!(@check, $t, atomic::AtomicU8, $a, $atomic_op);
atomic!(@check, $t, atomic::AtomicU16, $a, $atomic_op);
atomic!(@check, $t, atomic::AtomicU32, $a, $atomic_op);
#[cfg(target_has_atomic = "64")]
atomic!(@check, $t, atomic::AtomicU64, $a, $atomic_op);
// TODO: AtomicU128 is unstable
// atomic!(@check, $t, atomic::AtomicU128, $a, $atomic_op);
break $fallback_op;
}
「对得上」的定义在 can_transmute(997 行):
const fn can_transmute<A, B>() -> bool {
// Sizes must be equal, but alignment of `A` must be greater or equal than that of `B`.
(mem::size_of::<A>() == mem::size_of::<B>()) & (mem::align_of::<A>() >= mem::align_of::<B>())
}
大小相等还不够,对齐也要够。 原子指令要求地址按自身宽度对齐,(u32, u32) 是 8 字节、但只按 4 对齐,放进 AtomicCell 后地址可能是 4 的奇数倍,不能当 AtomicU64 用,只能走锁。
这些判断全是 size_of / align_of 的常量比较,if 在编译期就被折叠掉,所以 is_lock_free(154 行)可以是 const fn,每种 T 的 load 也只编译出其中一条路径。() 单独一档:AtomicUnit 的所有方法都是空的。
u128 一律走锁,原因就是那行被注释掉的 AtomicU128:它在 stable Rust 上还不可用。
原子路径:固定的内存序
原子路径不让调用方选 Ordering,文档写明:load 用 Acquire,store 用 Release,swap、compare_exchange、fetch_* 用 AcqRel。也就是说,AtomicCell 永远至少是「发布 / 获取」语义,没有 Relaxed 版本,也没有 SeqCst 版本。
store 对需要析构的 T 不走 atomic_store,而是 drop(self.swap(val))(171 行),保证旧值被析构。
锁路径:67 把 seqlock
放不进原生原子的 T,按 AtomicCell 自己的地址挑一把全局锁,lock(1012 行):
fn lock(addr: usize) -> &'static SeqLock {
// The number of locks is a prime number because we want to make sure `addr % LEN` gets
// dispersed across all locks.
// ...
const LEN: usize = 67;
const L: CachePadded<SeqLock> = CachePadded::new(SeqLock::new());
static LOCKS: [CachePadded<SeqLock>; LEN] = [L; LEN];
&LOCKS[addr % LEN]
}
- 为什么是素数:注释的解释是,地址总按某个 2 的幂对齐。
LEN若是偶数,addr % LEN也只会是偶数,一半的锁永远用不上;还可能出现地址恰好是 3 的倍数的布局(注释举了一个repr(C)结构体切片的例子),所以干脆取一个够大的素数。 - 为什么是 67、为什么每把锁
CachePadded:2023-12 的06e851f8(#1055,0.8.18 的「Improve scalability ofAtomicCellfallback」)把原来 97 把不填充的锁换成 67 把各占一条缓存行的锁。不填充时相邻几把锁挤在同一条缓存行上,两个线程用的明明是不同的锁,也会互相拖慢。aarch64 上这张表占 67 × 128 = 8576 字节。 - 锁是全进程共享的:两个毫不相干的
AtomicCell<u128>,地址模 67 相同就用同一把锁。下面的实验专门测了这种情况。
SeqLock(seq_lock.rs)是一个 AtomicUsize:值为 1 表示有人在写,否则是一个偶数版本号。
- 写:
swap(1, Acquire)抢锁,抢不到就Backoff::snooze(从不park,见 Backoff);释放时把版本号加 2 写回(Release)。 - 读(
atomic_load的锁分支):先乐观读,记下版本号,read_volatile拷出值,fence(Acquire)后版本号没变就用这个值;变了,就老老实实拿写锁读一次,然后abort()放锁、不加版本号,免得惊动其他乐观读者。
源码在乐观读那里承认这是数据竞争:
// We need a volatile read here because other threads might concurrently modify the
// value. In theory, data races are *always* UB, even if we use volatile reads and
// discard the data when a data race is detected. The proper solution would be to
// do atomic reads and atomic writes, but we can't atomically read and write all
// kinds of data since `AtomicU8` is not available on stable Rust yet.
(注释里的「AtomicU8 还不稳定」早已过时,问题本身仍在:没有「按字节原子拷贝任意大小数据」的稳定 API。)TSan 的抑制文件 ci/tsan 也为它开了一行(怎么验证并发代码)。同样的「读到就丢」论证还会在 deque 的 Buffer::read 里再出现一次。
32 位平台换成 seq_lock_wide.rs:版本号拆成高低两个 AtomicUsize,防止 32 位计数器回绕后乐观读把一次过时的拷贝当成有效(atomic/mod.rs 用 cfg_attr(..., path = "seq_lock_wide.rs") 切换)。cfg(crossbeam_loom) 下 AtomicCell 整个不编译:loom 的原子类型内存表示和底层类型不同,没法 transmute。
compare_exchange:先比字节,再比 Eq
atomic_compare_exchange_weak(1165 行)的原子分支:
loop {
match a.compare_exchange_weak(current_raw, new_raw, Ordering::AcqRel, Ordering::Acquire) {
Ok(_) => break Ok(current),
Err(previous_raw) => {
let previous = mem::transmute_copy(&previous_raw);
if !T::eq(&previous, ¤t) {
break Err(previous);
}
// The compare-exchange operation has failed and didn't store `new`. The
// failure is either spurious, or `previous` was semantically equal to
// `current` but not byte-equal. Let's retry with `previous` as the new
// `current`.
current = previous;
current_raw = previous_raw;
}
}
}
硬件 CAS 只会按字节比较。字节不同、Eq 却认为相等的情况(比如 T 里有填充字节,或者 Eq 只比部分字段)会被当成「语义上相等」:拿现值重试,直到成功或者真的不相等。锁分支直接用 T::eq 比。所以公开的 compare_exchange 虽然内部用 _weak,对调用方表现为强 CAS:只要 Eq 意义上等于 current,就一定成功。
实验 E2:哪些类型 lock-free,锁到底多慢
断言:只有大小和对齐都对得上原生原子的类型才 lock-free;锁路径在竞争下会慢,两个不相干的 cell 若映射到同一把锁也会互相干扰。
方法(exp-crossbeam/atomic-cell-lockfree):
- 对一组类型打印
size_of、align_of、AtomicCell::<T>::is_lock_free()。 - 单线程各做 500 万次
fetch_add:AtomicCell<u64>(原子)对AtomicCell<u128>(锁)。 - 两线程对同一个 cell 各做 500 万次
fetch_add。 - 两线程各对自己的
AtomicCell<u128>做 500 万次:一组两个 cell 用不同的锁,一组用同一把锁。构造方法:68 个CachePadded<AtomicCell<u128>>排成数组,第 0 个和第 67 个地址差 67 × 128,模 67 相同;第 0 个和第 1 个不同。
let cells: Vec<CachePadded<AtomicCell<u128>>> =
(0..68).map(|_| CachePadded::new(AtomicCell::new(0))).collect();
// addr(0) % 67 == addr(67) % 67, addr(0) % 67 != addr(1) % 67
two_cells(&cells[0], &cells[1]); // different locks
two_cells(&cells[0], &cells[67]); // same lock
结果:分三部分。
类型表(与负载无关,跑 3 次相同):
u8 size=1 align=1 lock_free=true
u32 size=4 align=4 lock_free=true
u64 size=8 align=8 lock_free=true
usize size=8 align=8 lock_free=true
() size=0 align=1 lock_free=true
u128 size=16 align=16 lock_free=false
Pair(u64,u64) size=16 align=8 lock_free=false
[u8;3] size=3 align=1 lock_free=false
[u8;4] size=4 align=1 lock_free=false
(u32,u16) size=8 align=4 lock_free=false
align(8){u32,u16} size=8 align=8 lock_free=true
(u32,u32) size=8 align=4 lock_free=false
Instant size=16 align=8 lock_free=false
Duration size=16 align=8 lock_free=false
Option<u64> size=16 align=8 lock_free=false
Option<u32> size=8 align=4 lock_free=false
Option<NonZeroU64> size=8 align=8 lock_free=true
f64 size=8 align=8 lock_free=true
char size=4 align=4 lock_free=true
- 断言成立:
[u8; 4](4 字节、对齐 1)、(u32, u32)、Option<u32>(8 字节、对齐 4)大小都对得上,就是对齐不够,全部走锁;把(u32, u16)包进一个#[repr(align(8))]的结构体,同样 8 字节就变成 lock-free。这是反向验证:只改对齐,结果翻转。 Option<u64>是 16 字节,走锁;Option<NonZeroU64>借助空位优化还是 8 字节,lock-free。Instant在本机是 16 字节(秒 + 纳秒),所以 tick channel 的AtomicCell<Instant>走锁;Linux x86_64 上的Instant同样是 16 字节。
吞吐(每格是该轮 5 次的中位数,毫秒;三轮分别在 17:08、17:21、17:32 开始,开始时负载 2.89、2.74、2.99):
| 第一轮 | 第二轮 | 第三轮 | |
|---|---|---|---|
单线程 500 万次 fetch_add:u64 / u128 | 19.6 / 24.8 | 20.2 / 24.0 | 20.1 / 24.3 |
两线程共用一个 cell,各 500 万次:u64(原子) | 98 | 134 | 148 |
同上:u128(锁) | 75 | 107 | 109 |
两线程各用自己的 u128 cell,不同的锁 | 41 | 25 | 25 |
两线程各用自己的 u128 cell,同一把锁 | 99 | 195 | 227 |
共用 u128 时,同一线程连续拿到锁的平均次数 | 980–2449 | 32–56 | 11–13 |
- 没竞争时锁路径几乎不贵:每次
fetch_add约 4.0 ns 对 4.9 ns。seqlock 的写只是一次swap加一次store。 - 规划时的预测不成立。 计划里写「两线程
AtomicCell<u128>应比u64慢一个量级以上」,实测三轮里u128反而都比u64快 20%–30%。最后一行给了一个线索:这把 seqlock 是不公平的自旋锁,拿不到锁的线程在Backoff::snooze里自旋、让出,持锁的线程放锁后马上又能抢到,于是一个线程常常连续做十几到上千次,缓存行不用每次都在两个核之间搬。原生fetch_add每次都要拿到缓存行的独占权,两个核交替得更频繁(这一半是推测,没有测u64的交替次数)。连续次数在三轮之间差两个数量级,说明它强烈依赖调度,不能当成稳定性质。 - 不相干的 cell 撞到同一把锁,代价是真实的:第二、三轮里「各用各的、不同的锁」是 25 ms,和单线程一样,说明 67 把锁各占一条缓存行确实互不干扰;换成恰好映射到同一把锁的两个 cell,就变成 195–227 ms,慢 8–9 倍。这比两个线程争同一个 cell 还慢,原因没有查明。
- 结论:
AtomicCell<T>的锁路径在低竞争时没什么代价;高竞争时,原生原子也不见得更快。真正要小心的是is_lock_free() == false的 cell 很多、又被不同线程高频写的情况,67 把锁会被不相干的数据共享。
小结
AtomicCell<T>对()和大小、对齐都能对上AtomicU8..AtomicU64的T用原子指令(Acquire/Release/AcqRel,不可选),其余共用 67 把按addr % 67选择的全局 seqlock。is_lock_free()是const fn,判断只看size_of/align_of;u128、(u32, u32)、Instant、Option<u64>都不是 lock-free。- 锁表 0.8.18 起是 67 把、各自
CachePadded;锁是全进程共享的,不相干的 cell 也可能撞到同一把。 - 锁路径的乐观读是
read_volatile+ 版本号校验,源码自认技术上是数据竞争。 compare_exchange硬件按字节比,失败后再用Eq判断,字节不同但Eq相等时重试。- M2 Max 实测(E2):无竞争时锁路径每次只多约 1 ns;两线程争同一个 cell 时
u128(锁)不比u64(原子)慢;两个不相干的 cell 映射到同一把锁时慢 8–9 倍。