Rust in Everything
基于 parking_lot 0.12.5

设计取舍

主线位置:四条主线的汇总。第 1 条是「全局停车场的一生」和横线「回调在桶锁里」的取舍,第 2 条在「一次有竞争的 lock / unlock」上,第 3 条在「一次 notify_all」上,第 4–7 条是 API 层的选择,第 8 条是整卷的背景。机制都在前面各章,这里每条只写做法、换来什么、代价,证据链到有数字的那一页。
flowchart TB
  lot["1 等待队列外置到全局哈希表"] --> c1["表只增不减、桶共享、unsafe 回调契约"]
  barge["2 允许插队 + 有人排队就不转"] --> c2["不公平,0.12.5 靠 1 ms 兜底"]
  rq["3 Condvar 的 requeue"] --> c3["只能配自己的 Mutex、返回值含义变了"]
  wp["4 写者一到就挡新读者"] --> c4["同线程重复读死锁"]
  np["5 不毒化"] --> c5["panic 后的半更新数据无人知道"]
  gs["6 守卫默认 !Send,send_guard 全局开关"] --> c6["feature 合并的远程影响"]
  api["7 外壳与原始锁分离(lock_api)"] --> c7["正确性全在文档契约里"]
  std["8 和 std 的关系"] --> c8["快不快看场景,理由更多在功能"]

1. 等待队列外置到一张全局哈希表

做法:锁里只留状态位,等待线程挂在 parking_lot_core 的全局哈希表里,按锁的地址找桶;桶数保持在「停过车的线程数 × 3」以上,扩容时锁住全部桶换表,旧表永不释放、表不缩(停车场哈希表)。

换来:Mutex<()> 1 字节、RwLock<()> 8 字节、Condvar 8 字节、Once 1 字节,大小与平台无关;本机 std 是 16 / 16 / 16 / 8(E1,和 std Mutex 比)。锁可以 const 构造、没有 Drop、可以大量嵌进细粒度的数据结构。所有锁共用一套 park / unpark 协议,Mutex、RwLock、Condvar、Once 的慢路径几乎是同一个骨架。

代价:

  • 所有锁共享一张表:不同的锁哈希到同一个桶就共用一把桶锁、一条链表、一个公平计时器(eventual fairness)。实测这一点没有可测的影响:两把锁放在同一个桶、相邻桶、远处,吞吐差在 2% 以内(E14,已删的实验,结论留在 停车场哈希表)。
  • 表只增不减:E7 中扩容点是 6、11、22、43 个线程,表依次 16、32、64、128、256 个桶,线程全部退出后不释放、也不再分配。
  • 正确性靠 unsafe 契约:validate / timed_out / callback 都在桶锁里执行,不能 panic、不能再调停车场;key 必须是调用方控制的地址(park 与四种 unpark)。写错了不会编译失败,只会偶尔丢唤醒;11 动手 里把 validate 挪到桶锁外,10 次压测 10 次卡死。

2. 允许插队,有人排队就不再自旋

做法:lock_slow 第一步「Grab the lock if it isn't locked, even if there is a queue on it」;只有在没人停车时才自旋,一旦有人停车,新来的线程直接去停车。普通解锁不交接,只写 10 并叫醒一个让它回来重抢(RawMutex 的一字节状态机)。

换来:锁不必每次都经过一次唤醒(本机一次睡眠唤醒约 1.2–2.1 µs,忙等下限约 0.1 µs,E12,ThreadParker);放锁的线程马上再拿,缓存还热。锁外有真实工作时效果最明显:12 线程、锁外 200 ns,0.12.5 175.3 ns/op,本机 std 322.4(E2)。

代价:不公平。2 个线程、持锁 10 µs 时,0.12.5 一个线程最长连续拿 95 次(约 1 ms),靠的是每个桶上 0–1 ms 的随机计时器强制交接;0.13.0 去掉了这个计时器,同样的场景最长 17 000–19 800 次,持锁 1 ms 时一个线程拿走全部 2 000 次(E3,公平解锁、bump 与 FairMutex)。显式公平(unlock_fair / FairMutex)把 4 线程短临界区的吞吐压到约 6 成。另一面:锁外完全不做事的 12 线程微基准里,0.12.5 反而最慢(29.5 ns/op,本机 std 16.8,0.13.0 13.9)。0.13 的「先放锁再叫人」(#545)就是在改这里:解锁方不再在整个 unpark_one 期间占着锁(延后唤醒与守卫状态)。

3. Condvar 用 requeue,不叫醒所有人

做法:notify_all 调 unpark_requeue:mutex 被锁着就把全部等待者改 key、挪到 mutex 的队列,一个都不叫醒;之后由 mutex 的每次 unlock 逐个叫醒(Condvar)。

换来:没有「全醒、抢锁、大多数再睡」。E6 中 N = 64 时第一个等待者拿到锁 parking_lot 约 19–27 µs,本机 std 约 103–132 µs。也正因为 requeue 不叫醒,Condvar 只需一个指针。

代价:

  • Condvar 要直接改 RawMutex 的状态字节,所以只能配 parking_lot 自己的 Mutex:映射守卫、FairMutex 的守卫都是编译错误(坑)。
  • 同一时刻只能配一把 mutex,第二把在 wait 调用时立即 panic(E15,坑)。
  • 返回值成了「受影响的线程数」,持锁 notify_all 返回 N 而真正叫醒 0 个(坑)。
  • 收益在本机比预期小:总时间由持锁工作串行决定,两边相差 10% 以内;本机 std 的上下文切换只多 5%–15%。

4. RwLock:写者一到就挡住新读者

做法:写锁两步,先抢 WRITER_BIT(有读者也能抢到),再等读者走光;读者看见 WRITER_BIT 就排队,哪怕锁里全是读者(读、写与写者优先)。可升级读同一时刻只有一个(可升级读)。

换来:写者不会被读者流饿死。E4:11 个读者持续读、每 1 ms 一次写,写者等待 p50 5.9 µs、p99 90 µs;本机 std 36 µs / 1.7 ms(和 std RwLock 比)。

代价:同一线程拿着读锁再 read(),恰好有写者排队时死锁(E5,3/3,坑);要用 read_recursive 显式打破规矩,写者等待的中位数随之涨到约 280 µs。可升级读者之间串行,同线程再拿就自锁(坑)。

5. 不毒化

做法:守卫 drop 只调 unlock(),不看线程是否在 panic;Mutex / RwLock 的文档把「No poisoning」列为与 std 的差异。只有 Once 毒化。

换来:lock() 直接返回守卫,不用 unwrap(),守卫小 8 字节(std 多一个 poison::Guard);一个线程 panic 不会通过 unwrap() 连锁传给所有用这把锁的线程。

代价:panic 后的半更新数据无人知道。实测两次写之间 panic,下一次 lock() 正常返回,不变量 a + b == 100 已变成 90;std 返回 PoisonError(坑)。Once 的毒化又和 std OnceLock 不一样,初始化失败时等待者全部跟着 panic(坑)。

6. 守卫默认 !Send,send_guard 是全局开关

做法:GuardMarker = GuardNoSend(*mut ());feature send_guard 把它换成 GuardSend,且与 deadlock_detection 互斥(GuardSend 与 send_guard)。

换来:默认值和死锁检测兼容(检测按加锁线程记账);守卫跨 .await 进 tokio::spawn 在编译期就被拦下。

代价:Cargo feature 全图合并。任何一个依赖开了 send_guard,整个程序的 parking_lot 守卫都变成 Send;再有一个依赖开了 deadlock_detection,parking_lot 本身就编译失败(坑)。tokio 为此在内部给 parking_lot 的类型套了一层 PhantomData<std::sync::...>,挡住外溢。开了 send_guard 编译通过,阻塞问题照旧:current_thread 运行时里持锁跨 .await 3/3 卡死(E9,坑)。

7. lock_api:外壳与原始锁分开

做法:unsafe trait RawMutex 只描述「加锁、试锁、解锁」,Mutex<R, T> 和整套守卫(map、Arc 守卫、unlocked、bump、try_lock_for)写一次,对任何实现者通用(raw trait 与契约)。

换来:三十来行 raw 代码换来完整 API。spin 0.9.9 用的就是这套外壳;本卷 11 动手 里一个基于停车场的 1 字节 mutex、一个 TTAS 自旋锁也都直接套上了。

代价:正确性全在文档契约里:互斥、unlock 只在持锁时调、GuardMarker 选对,类型系统一样都不检查;0.4.14 的契约文档连内存序要求都没有写(给自己的锁套外壳)。0.13 的 lock_api 0.5.0 把一部分搬进了类型(lock 返回本次加锁的状态,unlock 消费它,#544),代价是所有自定义锁都要改签名(延后唤醒与守卫状态)。

8. 和 std 的关系:「更快」已经不是默认结论

做法:parking_lot 的 README 用「When tested on x86_64 Linux」的数字说明它比 std 快(无竞争 1.5 倍、有竞争最多 5 倍),指的是当时的 std(parking_lot 解决什么问题)。

现状:std 现在在 Linux 等平台上用自己的 futex 实现(4 字节状态,队列在内核),在 macOS 上 Mutex / Condvar 是懒分配的 pthread,RwLock / Once 是自己的 queue.rs(和 std Mutex 比)。本机 E2(ns/op,0.12.5 / std / 0.13.0):

场景0.12.5std0.13.0
T=16.17.55.7
T=428.725.120.3
T=1229.516.813.9
T=12、锁外 200 ns175.3322.4160.9

锁外不做事时本机 std 比 0.12.5 快约 1.8 倍,锁外 200 ns 时反过来 0.12.5 快约 1.8 倍。这只是 macOS 上的结果;Linux 上 std 是 futex,本卷没有在任何服务器上测,结论不能搬过去。

所以今天选 parking_lot 的理由主要在功能:1 字节的锁、const 构造、显式的公平控制(unlock_fair / bump / FairMutex)、可升级读与降级、Arc 守卫与 map、写者等待时间可预期的 RwLock、调试用的死锁检测,以及 lock_api 这套可复用的外壳。只为「更快」换掉 std,需要先在自己的平台和负载上量一次。

小结

  • 全局停车场换来 1 字节的锁和统一的协议,代价是共享的表只增不减和一套写在文档里的 unsafe 回调契约。
  • 插队加「有人排队就不转」在锁外有工作时很快,代价是不公平;0.12.5 用 1 ms 兜底,0.13 交给用户显式选择。
  • requeue 避免惊群,代价是 Condvar 绑死在 parking_lot 的 Mutex 上、只能配一把、返回值含义改变。
  • 写者优先让写者等待可预期,代价是同线程重复读在有写者时死锁。
  • 不毒化让 API 简单,代价是 panic 后的数据状态无人知道。
  • 守卫的 Send 性由一个全图统一的 feature 决定,远程依赖能改变你的类型。
  • lock_api 把外壳做成通用件,正确性留给 unsafe impl 的作者。
  • 本机上「parking_lot 一定更快」不成立,选它主要为了功能。

💬 关联讨论

发起讨论
加载中...