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 用空间换时间:每个线程只去改自己那一份计数。
结构
/// 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> | |
|---|---|---|
| 内存 | 一把锁 + T | 8 × 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 次写一次 | ShardedLock | std::sync::RwLock | parking_lot::RwLock |
|---|---|---|---|
| 只读 | 145.1 / 128.4 | 2.1 / 2.3 | 5.7 / 5.7 |
| 10000 | 114.4 / 107.8 | 2.4 / 2.4 | 5.7 / 5.4 |
| 1000 | 83.2 / 83.7 | 3.2 / 2.9 | 5.6 / 5.2 |
| 100 | 36.1 / 36.1 | 9.6 / 9.4 | 5.5 / 5.4 |
| 10 | 14.1 / 14.7 | 14.5 / 14.8 | 10.6 / 9.7 |
| 1(全写) | 2.4 / 2.9 | 25.4 / 29.3 | 35.9 / 35.9 |
(负载约 4 时的预跑结果顺序相同:只读 116 / 2.5 / 8.4,全写 2.3 / 26.5 / 34.4。)
- 只读时差两个数量级:12 个线程只读,
ShardedLock每秒 1.3 亿到 1.5 亿次,stdRwLock约 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。