和 std Mutex 比
主线位置:主线 1 的对照组。同样是「两个线程抢一把锁」,parking_lot 在本机走停车场(每线程一个 pthread 条件变量),std 走 pthread mutex;在 Linux 上 std 走自己的 futex 实现。这一页给出两者的结构差别和本机的实测数字。
flowchart LR
subgraph pl["parking_lot::Mutex<T>(任何平台)"]
b1["AtomicU8 状态"] --> q1["全局哈希表的桶:等待队列"]
q1 --> tp["每线程 ThreadParker(macOS:pthread mutex + cond)"]
end
subgraph lx["std::sync::Mutex<T>,Linux"]
f["AtomicU32 futex 字 + 毒化标志"] --> k["内核 futex 等待队列"]
end
subgraph mac["std::sync::Mutex<T>,macOS"]
ob["OnceBox 指针 + 毒化标志"] --> pm["堆上的 pthread_mutex_t(首次加锁时分配)"]
end
std 按平台选实现
rust-src 1.99.1 的 sys/sync/mutex/mod.rs:1-27:Windows(非 win7)、Linux、Android、FreeBSD、OpenBSD、DragonFly、带原子的 wasm、Hermit 等用 futex.rs;其余 target_family = "unix"(本机 macOS 在这一支)用 pthread.rs。
- futex 版(
mutex/futex.rs:7-13):一个u32三种值UNLOCKED/LOCKED/CONTENDED,与 parking_lot 的两位思路相近,区别在于等待队列放在内核里(futex_wait以这个字的地址为 key),而 parking_lot 的队列在用户态的全局哈希表里。 - pthread 版(
mutex/pthread.rs:8-28):Mutex { pal: OnceBox<pal::Mutex> },第一次lock时Box::pin一个pthread_mutex_t并初始化。所以 stdMutex::new能是const fn,代价是一次堆分配和每次加锁多一层指针。
同一目录下另外三种原语在 macOS 上的选择:Condvar 也是 pthread(condvar/mod.rs:18-24);RwLock 和 Once 不用 pthread,而是 std 自己的 queue.rs(rwlock/mod.rs:18-27、once/mod.rs:27-36)。
所以本机的对比是「parking_lot 的停车场」对「macOS 的 pthread mutex」。生产服务器是 Linux,那里 std 是 futex,结论不能直接搬过去;本卷不在任何服务器上做实验,Linux 一侧只有下面的大小(编译期断言)是确定的。
std 的 Mutex<T> 本身是 { inner: sys::Mutex, poison: poison::Flag, data: UnsafeCell<T> }(sync/poison/mutex.rs:227-231),Flag 在 panic = "unwind" 时是一个 Atomic<bool>(sync/poison.rs:82-85)。
实验 E1:大小
方法(exp-parking_lot/sizes):本机打印 size_of / align_of;Linux 没法运行,改成编译期断言:const _: () = assert!(size_of::<T>() == N);,对 aarch64-unknown-linux-gnu 和 x86_64-unknown-linux-gnu 各 cargo check 一次,两个目标都通过。反向验证:打开一个 feature,加一条故意写错的断言(把 Linux 上的 Mutex<()> 写成 macOS 的 16),cargo check 报 evaluation panicked: assertion failed,说明这些断言确实被求值了。
| 类型 | parking_lot | std,macOS 实测 | std,Linux(编译期断言) |
|---|---|---|---|
Mutex<()> | 1 | 16 | 8 |
Mutex<u8> | 2 | 16 | 8 |
Mutex<u64> | 16 | 24 | 16 |
FairMutex<()> | 1 | — | — |
ReentrantMutex<()> | 24 | — | — |
MutexGuard<'_, u64> | 8 | 16 | 16 |
Condvar | 8 | 16 | 4 |
Once | 1 | 8 | 4 |
字节数。parking_lot 的大小与平台无关(同一组断言在两个 Linux 目标上也通过)。std 在 macOS 上的 16 字节是 OnceBox 的一个指针加一个 bool 毒化标志再按 8 对齐;Linux 上是 4 字节的 futex 字加 1 字节标志再按 4 对齐,8 字节。std 的 MutexGuard 比 parking_lot 的多出一个 poison::Guard(一个 bool,记录加锁时线程是否已在 panic,用来决定 drop 时要不要置毒化标志),对齐后多 8 字节。
Mutex<u64> 两边都是 16:数据要按 8 对齐,parking_lot 的 1 字节状态后面补了 7 字节。锁的「小」只有在数据本身很小、或者大量锁并排存放时才体现出来。
实验 E2:吞吐与单次加锁延迟
断言:无竞争时两者同量级;有竞争时谁快取决于临界区外的工作量;0.13.0 的延后唤醒在竞争下更快。
方法(exp-parking_lot/mutex-throughput,0.13.0 列在 exp-parking_lot/v013-mutex-throughput,同一份源码,都不开 deadlock_detection):T 个线程共享一把 Mutex<u64>,每个线程做 iters 次「加锁、加一、解锁」,锁外可选忙等 out 纳秒。吞吐 = 墙钟 / 总次数(ns/op),结束时断言计数正确。尾延迟:每个线程每 64 次操作计一次单次 lock() 调用的耗时(调用前后各取一次 Instant::now()),合并所有线程的样本取分位数。
for i in 0..iters {
let timed = sample && i % 64 == 0;
let ns = lock.inc(timed); // timed: Instant::now(); lock(); elapsed()
if timed { lat.push(ns); }
busy(out_ns);
}
结果(M2 Max,8 性能核 + 4 能效核,macOS 不能绑核;22:29–22:31 连续跑完,开始时 1 / 5 分钟负载 2.66 / 2.99,结束时被实验自己的 12 个线程推到 8.70 / 4.66)。每格是 3 轮的中位数,括号里是 3 轮的范围;延迟单位 µs,p50 在 macOS 上被 Instant 的 41.7 ns 分辨率量化,只列 p99 以上:
| 场景 | 指标 | 0.12.5 | std(pthread) | 0.13.0 |
|---|---|---|---|---|
| T=1 | ns/op | 6.1(6.1–6.1) | 7.5(7.5–7.5) | 5.7(5.6–5.7) |
| T=4,锁外 0 | ns/op | 28.7(28.4–29.0) | 25.1(23.3–25.3) | 20.3(20.2–20.5) |
| p99 / p99.9 | 2.1 / 12.1 | 3.9 / 30.3 | 3.0 / 11.9 | |
| T=12,锁外 0 | ns/op | 29.5(29.1–29.8) | 16.8(16.7–16.8) | 13.9(13.9–14.1) |
| p99 / p99.9 / 最大 | 22.7 / 88.0 / 304 | 7.4 / 97.0 / 5177 | 17.0 / 62.4 / 479 | |
| T=12,锁外 200 ns | ns/op | 175.3(173.8–177.9) | 322.4(321.4–325.2) | 160.9(158.4–162.4) |
| p99 / p99.9 / 最大 | 35.8 / 92.0 / 230 | 55.5 / 118.0 / 469 | 22.7 / 54.2 / 136 |
几点读法:
- 无竞争(T=1)三者同量级,0.13.0 最快、std 最慢,差别在 1–2 ns。这时完全不走慢路径,差别只能来自快路径的指令;本页没有看反汇编,不下结论。
- T=4、T=12 且锁外没有工作时,0.12.5 最慢:12 线程时 std 的吞吐是它的 1.8 倍,0.13.0 是 2.1 倍。这是最极端的形状:12 个线程除了抢锁什么都不做。
- 锁外有 200 ns 工作时顺序反过来:0.12.5 比 std 快 1.8 倍,0.13.0 又比 0.12.5 快约 9%。
- 尾延迟不跟吞吐走:T=12、锁外 0 时 std 的吞吐高、p99 也低(7.4 µs 对 22.7 µs),但三轮里 std 各有一次 3.7–5.7 ms 的单次加锁,parking_lot 两个版本的最大值都在 0.3–0.6 ms。锁外 200 ns 时 0.12.5 的 p99 / p99.9 都比 std 低,0.13.0 更低。
- 和规划时的数字不一样:规划阶段 0.12.5 一侧开着
deadlock_detection,那时 T=4 约 19–20 ns、T=12 约 18–20 ns,比现在不开时还快;锁外 200 ns 时是 216–244 ns,比现在慢。死锁检测让每次加解锁多做一点线程局部的工作,在「锁外什么都不做」的场景里这等于给每个线程加了一段锁外工作,争用反而变轻;有真实锁外工作时它只是开销。这个解释是推测,没有单独验证。无论如何,带着这个 feature 量出来的数字不能代表默认配置。
反向验证:把延迟采样关掉(每 64 次计时一次的那两次 Instant::now() 不再执行)再跑 T=1 和 T=12:T=1 三列 5.4 / 6.8 / 5.0,T=12 三列 25.7 / 15.4 / 11.6。采样让 ns/op 高出 10%–20%,三者的先后顺序不变,所以上表的比较成立,但绝对值以不采样的为准。
0.13.0 在竞争下更快,和它「先放锁、再叫醒」的改动方向一致(延后唤醒与守卫状态),不过这组实验没有把那一处改动单独隔离出来。
小结
- std 的
Mutex按平台选实现:Linux 等是 futex(用户态一个u32,队列在内核),macOS 是懒分配的 pthread mutex;本机对比只代表 macOS。 - 大小:parking_lot
Mutex<()>1 字节,std 在 macOS 上 16、Linux 上 8(编译期断言核实);Mutex<u64>两边都是 16。 - E2(本机):无竞争三者都在 5.7–7.5 ns;12 线程且锁外不做事时 0.12.5 最慢(29.5 ns/op,std 16.8、0.13.0 13.9);锁外有 200 ns 工作时 0.12.5 比 std 快 1.8 倍;std 偶尔出现毫秒级的单次加锁。没有一句话的结论。
- std 有毒化,parking_lot 没有;std 的守卫因此多 8 字节。