Rust in Everything
基于 crossbeam-utils 0.8.23

AtomicCell 不是 lock-free

主线位置:不在三条调用链上,属于内存序横线的「锁回退」一类。机制和实验 E2 在 AtomicCell。

flowchart TB
  t["AtomicCell<T>"] --> q{"size 等于某个 AtomicU8..U64,且 align 不小于它?"}
  q -->|是| at["原生原子指令"]
  q -->|"否:u128、(u32, u16)、Instant ..."| lock["按 addr % 67 选一把全局 seqlock"]
  lock --> share["不相干的 cell 可能共用同一把锁"]

现象

AtomicCell<T> 的名字和 API 都像 AtomicU64,于是被用在信号处理函数、实时线程、或者「不能拿锁」的地方:

static LAST_SEEN: AtomicCell<(u32, u16)> = AtomicCell::new((0, 0));

它能编译、能用,但每次 load / store 都在拿一把全局的 seqlock。不会有任何警告。

实测

实验 E2(exp-crossbeam/atomic-cell-lockfree/,M2 Max,跑 3 次相同)打印 size_of、align_of 和 AtomicCell::<T>::is_lock_free(),摘几行:

u64                    size=8   align=8   lock_free=true
u128                   size=16  align=16  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
Option<u64>            size=16  align=8   lock_free=false
Option<NonZeroU64>     size=8   align=8   lock_free=true

大小对上了也不够:[u8; 4]、(u32, u16)、(u32, u32) 都是对齐不够;把 (u32, u16) 包进 #[repr(align(8))] 的结构体就变成 lock-free。u128 一律走锁(AtomicU128 不稳定)。Instant 在 macOS 和 Linux x86_64 上都是 16 字节,所以 tick channel 的 AtomicCell<Instant> 走锁(at、tick、never)。

代价有多大(E2 吞吐部分,AtomicCell):

  • 单线程无竞争:u64 每次 fetch_add 约 4.0 ns,u128(锁)约 4.9 ns。
  • 两线程争同一个 cell:u128(锁)反而比 u64(原子)快 20%–30%,规划时「锁路径慢一个数量级」的预测不成立。
  • 两线程各写自己的 u128 cell,但两个 cell 恰好映射到同一把锁:从 25 ms 变成 195–227 ms,慢 8 到 9 倍。

所以多数时候问题不在速度,而在两点:语义上它会「拿锁」(自旋等待),不适合不能等待的上下文;以及全进程共享的锁表让不相干的数据互相干扰。

从哪来

is_lock_free 是 const fn,判断只看类型:can_transmute 要求 size_of::<T>() == size_of::<Atomic*>() 且 align_of::<T>() >= align_of::<Atomic*>()。都不满足就落到 lock(addr):按 addr % 67 从 67 把全局 seqlock 里选一把(LEN = 67,每把 CachePadded)。读是乐观的(读版本号、拷贝、再校验),写要拿锁,拿不到就在 Backoff::snooze 里自旋、让出。

怎么改

  • 在编译期断言:需要无锁的地方加一行

    #[repr(C, align(8))]
    #[derive(Clone, Copy)]
    struct Packed {
      a: u32,
      b: u16,
    }
    
    const _: () = assert!(AtomicCell::<Packed>::is_lock_free());
    

    类型改了、对齐变了,编译就失败。对 (u32, u16) 写同样的断言(exp-crossbeam/pitfalls/src/bin/lockfree_assert.rs,--features bad):

    error[E0080]: evaluation panicked: assertion failed: AtomicCell::<(u32, u16)>::is_lock_free()
    
  • 调整类型:两个小字段打包成一个 u64(自己移位),或者用 #[repr(C, align(8))] 的结构体让对齐达到 8;Option<u64> 换成 Option<NonZeroU64> 或用一个哨兵值。

  • 本来就要 16 字节:u128、Instant 没有稳定的无锁办法。低竞争时锁路径够用;高竞争时考虑 Mutex(语义明确)或者换表示(比如把 Instant 换成相对某个起点的纳秒数 u64)。

  • 很多走锁的 cell 被不同线程高频写:这是 67 把锁会互相干扰的情形;改成原生原子或分开加锁。

小结

  • AtomicCell<T> 只有在大小相等且对齐足够时才是原子指令,其余走全局 67 把 seqlock;类型签名上看不出来,只有 is_lock_free() 知道。
  • E2:无竞争时锁路径只多约 1 ns,两线程争同一个 cell 时也不比原子慢;不相干的 cell 撞同一把锁时慢 8 到 9 倍。
  • 用 const 断言把「必须无锁」写进代码。

💬 关联讨论

发起讨论
加载中...