Rust in Everything
基于 crossbeam-utils 0.8.23

ShardedLock

主线位置:不在三条主线上。它是 CachePadded 的一个直接应用:把一把读写锁的「读者计数」拆成 8 份,各放一条缓存行。

flowchart TB
  r1["线程 0 read"] --> s0["分片 0:RwLock<()>"]
  r2["线程 5 read"] --> s5["分片 5:RwLock<()>"]
  r3["线程 8 read"] --> s0
  w["write:依次锁 0..7,倒序释放"] --> s0
  w --> s1["分片 1..4"]
  w --> s5
  w --> s7["分片 6、7"]
  s0 -.-> v["value: UnsafeCell<T>"]
  s5 -.-> v

普通读写锁的读锁也要写共享状态:read() 至少要把读者计数加一,drop 时减一。所有读者改的是同一个原子变量,这条缓存行在各核之间来回搬,读者越多越慢,哪怕从来没有写者。ShardedLock 用空间换时间:每个线程只去改自己那一份计数。

结构

sharded_lock.rs:18:

/// The number of shards per sharded lock. Must be a power of two.
const NUM_SHARDS: usize = 8;

/// A shard containing a single reader-writer lock.
struct Shard {
    /// The inner reader-writer lock.
    lock: RwLock<()>,

    /// The write-guard keeping this shard locked.
    ///
    /// Write operations will lock each shard and store the guard here. These guards get dropped at
    /// the same time the big guard is dropped.
    write_guard: UnsafeCell<MaybeUninit<RwLockWriteGuard<'static, ()>>>,
}

ShardedLock<T> 就是 shards: Box<[CachePadded<Shard>]> 加 value: UnsafeCell<T>。分片里的 RwLock<()> 不保护任何数据,只用来计数和排队;真正的值只有一份。

  • 读(286 行):current_index() & (NUM_SHARDS - 1) 选一个分片,只对它 read()。
  • 写(410 行):按 0 到 7 的顺序对每个分片 write(),把每个分片的写守卫 transmute 成 'static 存进该分片的 write_guard 槽;8 个都拿到才返回一个不含任何字段的 ShardedLockWriteGuard。它 drop 时(525 行)倒序把 8 个守卫取出来 drop。所有写者都按同一顺序加锁,所以不会互相死锁。
  • poison:写的时候任意一个分片被 poison 都算 poison;is_poisoned() 只看分片 0(169 行),因为写者一定会锁到分片 0。读者 panic 不会 poison。
  • try_write 碰到某个分片 WouldBlock,把已经拿到的前 i 个倒序放掉再返回。

线程编号

分片按「线程编号」选,编号不是 ThreadId,而是一张全局表分配的小整数,579 行起:

struct ThreadIndices {
    /// Mapping from `ThreadId` to thread index.
    mapping: HashMap<ThreadId, usize>,
    /// A list of free indices.
    free_list: Vec<usize>,
    /// The next index to allocate if the free list is empty.
    next_index: usize,
}

fn thread_indices() -> &'static Mutex<ThreadIndices> {
    static THREAD_INDICES: OnceLock<Mutex<ThreadIndices>> = OnceLock::new();
    // ...
}

std::thread_local! {
    static REGISTRATION: Registration = {
        // 第一次用时拿全局锁:优先复用 free_list 里的编号,否则 next_index += 1
    };
}

一个线程第一次读 ShardedLock 时,在线程本地的 REGISTRATION 里登记一个编号(这一次要拿全局 Mutex),之后每次读只是读一个线程本地变量。线程退出时 Registration::drop 把编号还回 free_list,新线程优先复用。编号连续分配,所以前 8 个线程正好各占一个分片,第 9 个线程和第 1 个共用分片 0。线程本地变量已经析构时(线程退出过程中),current_index() 返回 None,退回分片 0。

这张表是全进程共用的,和具体哪把 ShardedLock 无关。

代价

RwLock<T>ShardedLock<T>
内存一把锁 + T8 × CachePadded<Shard>(aarch64 上 8 × 128 = 1 KiB,堆上)+ T
读一次原子 RMW,所有读者同一条缓存行读线程本地编号 + 一次原子 RMW,最多 8 组读者分开
写一次加锁8 次加锁、8 次解锁;要等 8 个分片上的读者都走光
写者饥饿取决于底层实现同左,文档写明优先策略依赖操作系统的 RwLock 实现

文档对适用场景的描述只有一句:读比 RwLock 快,写比 RwLock 慢。

tracing 卷的 reload 里引用的那段 tracing-subscriber 源码注释说,reload::Layer 曾经用过 crossbeam 的 ShardedRwLock,后来换掉了。这个名字在 crossbeam 仓库的提交历史里搜不到(git log -S ShardedRwLock 为空);它指的应该就是 ShardedLock,属推测。

实验 E10:写比例从 0 到 100%

断言:只读或几乎只读时 ShardedLock 比 std::sync::RwLock、parking_lot::RwLock 快;写的比例上去后反过来,全写时最慢。

方法(exp-crossbeam/sharded-lock):12 个线程(本机 8 性能核 + 4 能效核,macOS 不能绑核)同时开始,每个做 20 万次操作,每第 N 次是写(值加一),其余是读;N 取「只读」、10000、1000、100、10、1(全写)。三种锁各跑 5 轮取中位数,报总吞吐(百万次操作 / 秒)。每轮结束断言写的次数正确。

for i in 0..OPS {
  if every != 0 && i % every == every - 1 {
    lock.write();          // *guard += 1
  } else {
    black_box(lock.read()); // *guard
  }
}

结果:两次独立运行(17:15 和 17:38 开始,开始时负载 2.43 和 2.85),百万次操作 / 秒,每格是 5 轮的中位数,「/」两边是两次运行:

每 N 次写一次ShardedLockstd::sync::RwLockparking_lot::RwLock
只读145.1 / 128.42.1 / 2.35.7 / 5.7
10000114.4 / 107.82.4 / 2.45.7 / 5.4
100083.2 / 83.73.2 / 2.95.6 / 5.2
10036.1 / 36.19.6 / 9.45.5 / 5.4
1014.1 / 14.714.5 / 14.810.6 / 9.7
1(全写)2.4 / 2.925.4 / 29.335.9 / 35.9

(负载约 4 时的预跑结果顺序相同:只读 116 / 2.5 / 8.4,全写 2.3 / 26.5 / 34.4。)

  • 只读时差两个数量级:12 个线程只读,ShardedLock 每秒 1.3 亿到 1.5 亿次,std RwLock 约 200 万次,parking_lot 不到 600 万次。后两者每次读都要对同一个状态字做原子 RMW,12 个核抢一条缓存行;ShardedLock 的 12 个线程分散在 8 个分片上。
  • 交叉点在写占 10% 左右:N = 10 时 ShardedLock 和 std 打平(约 14.5),parking_lot 稍慢。
  • 全写时最慢,断言的反向验证成立:ShardedLock 每次写要锁 8 个分片,只有 std 的 1/10、parking_lot 的 1/12 到 1/15。
  • 一个反直觉的现象:std 和 parking_lot 在全写时比只读时快一个数量级(std 25–29 对 2.1–2.3)。只读时所有线程同时成功拿到读锁、同时改同一个计数;全写时一次只有一个线程能进,其余的排队或睡下,争用反而少。这个解释是推测,没有单独验证(例如没有统计 CAS 失败次数)。
  • 这组数字是「12 线程、临界区几乎为空」的极端形状:真实程序里临界区有活儿干,各种锁的差距都会被冲淡。

发布之后

main 上 0.8.23 之后有两处和 ShardedLock 有关的小改动,都没有发布:#1350 修正了 ShardedLockReadGuard / ShardedLockWriteGuard 的 Debug 实现,#1292 修正了 write 方法照抄 read 的文档。锁的行为本身没变。

小结

  • ShardedLock = 8 个 CachePadded<RwLock<()>> + 一份 UnsafeCell<T>;读锁只锁本线程编号对应的分片,写锁按 0..7 顺序锁全部、倒序释放。
  • 线程编号来自全进程一张 Mutex<ThreadIndices>,线程第一次读时登记,退出时归还;第 9 个线程开始与前面的线程共用分片。
  • 代价是 1 KiB 的分片、写要加 8 次锁;is_poisoned 只看分片 0。
  • M2 Max 上 12 线程实测(E10):只读时比 std RwLock 快约 60 倍;写占 10% 时打平;全写时只有 std 的 1/10。

💬 关联讨论

发起讨论
加载中...