Rust in Everything
基于 parking_lot 0.12.5

和 std RwLock 比

主线位置:主线 1 的读写锁对照组。parking_lot 的写者先占 WRITER_BIT 截断读者流,再在 addr + 1 上等读者走光;本机 std 的 queue.rs 是另一套写者优先的实现。这一页比较两者的结构,并量一个 crossbeam 卷没有量过的数:读者流不断时,写者要等多久。

flowchart LR
  subgraph pl["parking_lot::RwLock<T>"]
    s1["AtomicUsize:标志位 + 读者数"] --> q1["停车场:addr 与 addr+1 两条队列"]
  end
  subgraph mac["std::sync::RwLock<T>,macOS:queue.rs"]
    s2["AtomicPtr:标志位 + 等待节点链表指针"] --> q2["链表节点在等待线程的栈上"]
  end
  subgraph lx["std::sync::RwLock<T>,Linux:futex.rs"]
    s3["state: u32(30 位读者数 + 两个等待位)"] --> k["内核 futex"]
    s4["writer_notify: u32"] --> k
  end

std 的两种实现

rust-src 1.99.1 的 sys/sync/rwlock/mod.rs:1-27:Linux、Android、BSD、Windows(非 win7)等用 futex.rs;其余 unix(本机 macOS)用 queue.rs。和 Mutex 不同,macOS 上的 RwLock 不用 pthread_rwlock_t。

  • queue.rs:模块文档(1-31 行)列了不用 pthread 读写锁的理由(不可移动要堆分配、不能内联、某些平台有 bug、「On some platforms (e.g. macOS), the lock is very slow」),实现策略照搬 Windows SRWLOCK 和 usync 库:状态是一个指针,低 4 位是 LOCKED / QUEUED / QUEUE_LOCKED / DOWNGRADED,其余位指向等待节点的链表,节点放在等待线程自己的栈上。文档自述的性质:自适应(先有限地自旋)、除了每线程的 Thread 句柄之外不分配、「writer-preferring, even if some readers may still slip through」、「unfair」。读锁的快路径只在没有 QUEUED 时尝试(152 行):有人排队,新读者就去排队。
  • futex.rs:两个 u32(4-15 行),state 里 30 位读者数、一个「有读者在等」位、一个「有写者在等」位,writer_notify 专门用来叫醒写者。

std 自己的文档不承诺任何优先策略:「The priority policy of the lock is dependent on the underlying operating system's implementation, and this type does not guarantee that any particular policy will be used」,并给了同线程重复读可能死锁的例子(sync/poison/rwlock.rs:22-38);read() 的文档另写「might panic when called if the lock is already held by the current thread」(385-386 行)。parking_lot 则把策略写死在文档里:task-fair,有写者在等时读者阻塞(读、写与写者优先)。

parking_lotstd,macOS(queue.rs)std,Linux(futex.rs)
RwLock<()> 大小816(实测)12(编译期断言)
RwLock<u64> 大小1624(实测)24(编译期断言)
等待队列全局停车场,两个 key等待线程栈上的链表内核
写者在等时新读者阻塞(read_recursive 例外)文档称写者优先,「some readers may still slip through」未在本卷核实
升级 / 降级可升级读、三种降级只有写 → 读的 downgrade(1.92.0 起稳定)同左
毒化无写者 panic 时毒化同左

大小来自 E1(和 std Mutex 比那页的同一个实验)。std 的 RwLockWriteGuard::downgrade 在 rust-src 1.99.1 里标着 #[stable(feature = "rwlock_downgrade", since = "1.92.0")](sync/poison/rwlock.rs:882-883);parking_lot 的 downgrade 早就有,另有 std 没有的可升级读。

吞吐:引用 crossbeam 卷

crossbeam 卷的 ShardedLock 页已经在本机用 12 个线程扫过读写比例:只读时 std RwLock 约 2.1–2.3 百万次 / 秒、parking_lot 5.7(两次运行相同);全写时 std 25.4–29.3、parking_lot 35.9。这里不重测。那一页的推测「只读时所有线程同时改同一个计数」,在 parking_lot 这一侧可以从代码确认:每次 read() 都对同一个状态字做 CAS,失败就 spin_no_yield 退避重试(读、写与写者优先)。那组数字所用的实验 crate(exp-crossbeam/sharded-lock)依赖 parking_lot = "0.12"、锁文件里是 0.12.5,只开默认 feature,没有 deadlock_detection,和本卷的计时规矩一致,可以直接引用。

实验 E4:读者流不断时,写者等多久

断言:parking_lot 的写者不会被持续的读者流饿死,等待时间在「一个读临界区 + 一次唤醒」的量级;反过来让读者用 read_recursive(允许插到等待中的写者前面),写者应当明显等得更久——这是测法本身的反向验证。

方法(exp-parking_lot/rwlock-writer,0.13.0 列在 exp-parking_lot/v013-rwlock-writer,同一份源码,都不开 deadlock_detection):11 个读者线程循环「read() → 忙等 1 µs → 解锁」,锁外不做事;1 个写者线程每 1 ms 申请一次写锁,记录从调用 write() 到拿到锁的时间;跑 3 秒后读者停止。三种读法:parking_lot read()、read_recursive()(0.13.0 没有这个方法,换成 RecursiveRwLock::read())、std read()。再加一组「没有读者」作为下限。M2 Max(8P + 4E),每组 3 轮。

// writer thread
while !stop.load(Ordering::Relaxed) {
    next += Duration::from_millis(1);
    sleep_until(next);
    let t = Instant::now();
    lock.write_inc();              // *lock.write() += 1
    waits.push(t.elapsed().as_nanos() as u64);
}

结果(22:37–22:38,开始时 1 / 5 分钟负载 2.67 / 2.98,结束时被实验自己推到 7.47 / 4.45)。每格是 3 轮的中位数,括号里是 3 轮的范围;每轮写者约做 3000 次写:

实现读法写者等待 p50p99最大读者吞吐(百万次 / 秒)
0.12.5read()5.9 µs(5.8–8.6)90 µs(84–99)1.1 ms(0.9–2.0)5.16
0.13.0read()7.1 µs(5.3–7.5)95 µs(81–159)2.1 ms(1.1–10.0)4.98
std(queue.rs)read()36.0 µs(35.6–36.2)1.7 ms(1.4–2.6)11.6 ms(10.0–19.4)3.24
0.12.5read_recursive()280 µs(268–307)5.2 ms(4.0–5.4)52 ms(41–56)5.20
0.13.0RecursiveRwLock::read()258 µs(241–262)3.2 ms(3.2–8.1)27 ms(16–62)4.85
0.12.5 / std没有读者≤ 0.04 µs0.1–0.2 µs≤ 11 µs—
  • parking_lot 的写者不会被读者流饿死:p50 约 6 µs,大约是「等手上这批 1 µs 的读临界区结束 + 一次唤醒」;p99 不到 0.1 ms。0.13.0 与 0.12.5 在这一项上没有可分辨的差别。
  • 反向验证成立:读者改用 read_recursive(或 0.13.0 的 RecursiveRwLock)后,写者等待的中位数涨了约 45 倍,最大值到几十毫秒。11 个读者此起彼伏,读者数很少降到 0,递归读一直放读者进来,写者只能等到所有读者恰好同时不在锁里。这说明上面 6 µs 的结果确实来自「WRITER_BIT 挡住新读者」,而不是测法本身看不出差别。
  • 本机 std 介于两者之间:p50 36 µs,p99 和最大值比 parking_lot 高一个数量级以上,读者吞吐也低约 37%。queue.rs 自述「writer-preferring, even if some readers may still slip through」,数字和这句话一致:写者最终总能进去,但会被溜进来的读者拖慢。
  • 「没有读者」一组是下限:写锁的快路径一次 CAS,测到的就是 Instant::now() 的分辨率。

小结

  • std 的 RwLock 在 macOS 上是 std 自己的 queue.rs(写者优先、不公平、节点在等待线程栈上),在 Linux 上是两个 u32 的 futex 实现;parking_lot 是一个字加停车场的两条队列。
  • 大小:parking_lot RwLock<()> 8 字节,std 在 macOS 上 16、Linux 上 12。
  • 吞吐直接引用 crossbeam 卷:12 线程只读时 parking_lot 约为 std 的 2.5–2.7 倍,全写时 1.2–1.4 倍。
  • E4(本机,11 个读者 + 每 1 ms 一次写):parking_lot 写者等待 p50 约 6 µs、p99 约 0.1 ms;本机 std p50 36 µs、p99 约 1.7 ms;读者改用 read_recursive 后 p50 约 0.28 ms、最大 52 ms(反向验证)。
  • 同线程重复读:两边都会死锁(E5,读、写与写者优先);parking_lot 有 read_recursive 可以绕开,代价是写者可能等得很久。

💬 关联讨论

发起讨论
加载中...