Rust in Everything
基于 parking_lot 0.12.5

一个线程放锁后立刻又拿到,另一个线程等了几千次

主线位置:主线 1 的解锁分支。不公平的 unlock_slow 把状态写成 10 并叫醒一个停车者,被叫醒的线程要回到 lock_slow 开头去抢;解锁方此刻还在 CPU 上,下一条指令就是 lock(),几乎总能抢在前面(RawMutex 的一字节状态机)。

sequenceDiagram
  participant A as 线程 A(循环持锁)
  participant M as 状态
  participant B as 线程 B(停车中)
  A->>M: unlock_slow:写 10,叫醒 B(TOKEN_NORMAL)
  A->>M: 立刻 lock():CAS 拿到锁
  B->>B: 醒来(约 1–2 µs 后)
  B->>M: 抢锁失败,再停车
  Note over A,B: 0.12.5:桶上的计时器 0–1 ms 内到期,下一次解锁强制交接
  Note over A,B: 0.13.0 / 本机 std:没有这一步

现象

两个(或少数几个)线程用同一把锁,其中一个在循环里「加锁 → 干一段活 → 解锁 → 立刻再加锁」。另一个线程的进度远低于预期,甚至完全停滞。升级到 parking_lot 0.13 后,原来还算均匀的程序突然出现这种情况。

复现

E3(exp-parking_lot/eventual-fairness 与 exp-parking_lot/v013-eventual-fairness,同一份源码,不开 deadlock_detection):2 个线程循环加锁、记录持有者、持锁忙等 hold、解锁,锁外不做事。统计同一个线程最长连续拿到锁的次数(3 轮):

持锁总次数0.12.5 Mutexstd(本机 pthread)0.13.0 Mutex
10 µs100 00095 / 95 / 3668 388 / 21 950 / 37 10817 048 / 17 753 / 19 805
100 µs100 00042 / 17 / 1013 974 / 11 217 / 10 36253 351 / 99 563 / 83 217
1 ms2 0001 / 1 / 12 000 / 1 545 / 1 7782 000 / 2 000 / 2 000
10 ms3001 / 1 / 1299 / 300 / 268300 / 300 / 300

0.12.5 把连续独占限制在约 1 ms(10 µs × 95),持锁 ≥ 1 ms 时严格交替;0.13.0 和本机 std 都出现过一个线程拿走全部次数。反向验证:unlock_fair / FairMutex 在 4 线程、持锁 1 µs 时自取比例恒为 0(完整表见 公平解锁、bump 与 FairMutex)。

源码出处

  • 插队:lock_slow 第一步「Grab the lock if it isn't locked, even if there is a queue on it」(raw_mutex.rs:209-289)。
  • 兜底:unlock_slow 的回调里 force_fair || result.be_fair 为真才交接(291-323 行);be_fair 来自桶上的 FairTimeout,到期后把下一次定在 0–1 ms 之后(core/src/parking_lot.rs:98-149)。计时器是每个桶一个,只在真的叫醒了线程时才看(eventual fairness)。
  • 0.13.0 删掉了这套兜底:CHANGELOG「Removed automatic eventual fairness. Fair unlocking remains available through the explicit fair-unlock APIs.」(#533 中的 8cfdedb,去掉的与修正的)。

怎么改

公平要显式要,三个 API 在 0.12.5 和 0.13 里都有:

  • 长循环里定期让一下:MutexGuard::bump(&mut g)。有人在停车时公平地交出去再排队拿回;没人等时只是一次 Relaxed 读。
  • 这一次解锁要公平:MutexGuard::unlock_fair(g),有人停车就直接交接,锁不经过「放开」状态。
  • 整把锁都要公平:换成 FairMutex。代价是吞吐:E3 中 4 线程、持锁 1 µs 时从约 60 万次 / 秒降到约 36 万次 / 秒。注意 FairMutex 的守卫不能配 Condvar(映射守卫与 FairMutex 配 Condvar)。
  • 结构上避免:持锁时间长又频繁重入的循环,往往可以改成「锁里取一批、锁外处理、锁里放回」,持锁时间短了,插队的影响也小。

小结

  • parking_lot 的 Mutex 允许插队,放锁后立刻再加锁的线程常常自己抢回去。
  • 0.12.5 有最终公平兜底,连续独占约 1 ms 封顶;0.13.0 去掉了,本机 std 也没有,E3 中一个线程可以拿走全部次数。
  • 要公平就显式用 bump、unlock_fair 或 FairMutex,FairMutex 约让吞吐降到 6 成。
  • 依赖过 0.12 兜底的代码,升级 0.13 前要先改。

💬 关联讨论

发起讨论
加载中...