本文是对 A Rust match made in hell 的整理与翻译
内容结构概览
rust
1. Rust 基础回顾
1.1 if/else 是表达式
1.2 match:比 switch 强大得多
1.3 枚举(enum)与模式匹配
1.4 枚举变体携带关联数据
2. Clone 与 Copy
2.1 移动语义(Move)
2.2 通过引用传递(By Reference)
2.3 引用穿透(Borrow-through)
3. 锁(Mutex)
3.1 Mutex 的所有权模型
3.2 MutexGuard、Deref/DerefMut 与生命周期
3.3 值在何时被 Drop
4. 死锁案例分析
4.1 显而易见的死锁
4.2 不那么显眼的死锁(方法间嵌套锁)
4.3 重构避免死锁的常规手法
5. match + Mutex 的惊人组合
5.1 几段看似无害的代码
5.2 其中一段会死锁------你猜到了吗?
5.3 到底发生了什么:match 的临时值生命周期延长
6. Async Rust 中的相同问题
6.1 async/await 基本工作原理
6.2 持有同步锁跨越 await 点导致死锁
6.3 使用 tokio::sync::Mutex 解决
7. Clippy lint 的现状与不足
7.1 await_holding_lock lint 介绍
7.2 假阳性问题
7.3 不支持 parking_lot
7.4 无法检测闭包内部的情形
8. 作者真实踩坑:RwLock + match + async 的完美风暴
8.1 问题代码重现
8.2 GDB 调试分析
8.3 诊断困难的多重原因
8.4 最终修复方式
9. 社区反应与未来改进方向
一、Rust 基础回顾
1.1 if/else 是表达式
Rust 的 if/else 不仅是控制流语句,它们本身是表达式,可以直接用在赋值或函数调用中:
rust
fn is_good() -> bool { true }
fn main() {
let msg = if is_good() {
"It is good"
} else {
"It isn't good, yet"
};
println!("{msg}");
}
这弥补了 Rust 没有三元运算符(cond ? a : b)的"遗憾"------因为根本不需要。
1.2 match:比 switch 强大得多
match 是 Rust 中威力最强的控制流结构,它可以进行模式匹配,而不仅是等值比较:
rust
use rand::Rng;
fn main() {
let msg = match rand::thread_rng().gen_range(0..=10) {
10 => "Overwhelming victory",
5.. => "Victory",
_ => "Defeat",
};
println!("{msg}");
}
5.. 表示"5 及以上",_ 是兜底的通配符。Rust 要求 match 必须覆盖所有可能的值(穷举性),这是编译期的安全保证。
1.3 枚举(enum)与模式匹配
Rust 的 enum 是真正的代数数据类型(Sum Type),而不是简单的整数枚举。
对比下面两种写法:
rust
// 不推荐:布尔值参数,调用方看不出意图
fn process(secure: bool) { ... }
process(false); // false 是什么意思?
rust
// 推荐:自描述的枚举
pub enum Protection {
Secure,
Insecure,
}
fn process(prot: Protection) { ... }
process(Protection::Insecure); // 一目了然
使用枚举还有额外好处:可以在 IDE 中精准找到所有使用 Protection::Insecure 的地方,甚至可以标记为 #[deprecated],在编译时产生警告:
rust
pub enum Protection {
Secure,
#[deprecated = "using secure mode everywhere is now strongly recommended"]
Insecure,
}
arduino
warning: use of deprecated unit variant `Protection::Insecure`:
using secure mode everywhere is now strongly recommended
这是 Rust "语言 + 工具链"协同工作的体现------良好的诊断信息本身就是语言特性的一部分。
1.4 枚举变体携带关联数据
枚举变体可以持有数据,并在 match 中解构出来:
rust
pub enum Protection {
Secure(SecureVersion),
Insecure,
}
pub enum SecureVersion { V1, V2, V2_1 }
fn process(prot: Protection) {
match prot {
Protection::Secure(version) => {
println!("Hacker-safe thanks to protocol {:?}", version);
}
Protection::Insecure => {
println!("Come on in");
}
}
}
二、Clone 与 Copy
2.1 移动语义(Move)
Rust 默认使用移动语义。把一个值传入函数,所有权就转移过去了,原来的变量不能再使用:
rust
fn main() {
let prot = Protection::Secure(SecureVersion::V2_1);
process(prot);
process(prot); // 编译错误:prot 已被移走
}
go
error[E0382]: use of moved value: `prot`
如果类型是纯数据(没有堆内存),可以派生 Clone 和 Copy,让它在传递时自动复制:
rust
#[derive(Clone, Copy)]
pub enum Protection { ... }
但要注意:如果枚举变体包含的类型没有实现 Copy,整个枚举也无法实现 Copy。
2.2 通过引用传递(By Reference)
如果不想移动值,可以改为传递引用:
rust
fn process(prot: &Protection) { // 接受引用
match prot { ... }
}
fn main() {
let prot = Protection::Secure(SecureVersion::V2_1);
process(&prot); // 借出引用
process(&prot); // 可以多次借出
}
有意思的是,match 对此完全透明:无论 prot 是 Protection 还是 &Protection,match 的写法可以保持不变。在匹配时,Rust 会自动做"引用穿透"------如果匹配的是引用,解构出来的也是引用。
2.3 引用穿透(Borrow-through)
方法可以返回对 self 内部数据的引用:
rust
impl Foobar {
fn get(&self) -> &i64 {
&self.0
}
}
这个返回值的生命周期与 self 绑定。只要返回值还在使用,self 就无法被释放:
rust
let f = Foobar(134);
let a = f.get();
drop(f); // 编译错误:f 还被 a 借用着
println!("{a}"); // a 在这里仍然使用
这叫做引用穿透 ,是 Rust 借用检查器的核心机制之一。可变引用同理------通过 get_mut 获取的 &mut i64 会锁住整个 f,防止并发修改。
三、锁(Mutex)
3.1 Mutex 的所有权模型
Rust 不允许多个线程同时修改同一个值:
rust
// 编译错误:不能有多个可变借用
for _ in 0..3 {
s.spawn(|_| { counter += 1; });
}
Mutex<T> 是解决方案:锁拥有受保护的数据,只有持有锁时才能读写。
rust
use parking_lot::Mutex;
let counter = Mutex::new(0_u64);
for _ in 0..3 {
s.spawn(|_| {
for _ in 0..100_000 {
*counter.lock() += 1;
}
});
}
println!("final count: {}", counter.into_inner()); // 300000
3.2 MutexGuard、Deref/DerefMut 与生命周期
Mutex::lock() 返回的 MutexGuard<T> 实现了 Deref 和 DerefMut,让它像智能指针一样直接操作内部数据。
其生命周期与 Mutex 本身绑定------不能把 MutexGuard 带出 Mutex 的作用域:
rust
let mut guard = {
let counter = Mutex::new(0_u64);
counter.lock() // 编译错误:counter 的生命周期不够长
};
3.3 值在何时被 Drop
MutexGuard 在离开作用域时自动释放锁 。可以用显式 drop() 提前释放,也可以用代码块限制作用域:
rust
fn drop<T>(t: T) {} // drop 就是这么简单:拿走所有权,然后丢掉
{ let guard = m.lock(); /* 这里持有锁 */ } // 出了大括号就释放
四、死锁案例分析
4.1 显而易见的死锁
同一线程对同一个 Mutex 连续加锁两次,第二次会永远等第一次释放:
rust
let l = Mutex::new(0);
let _a = l.lock(); // 获取锁
let _b = l.lock(); // 永远等待,死锁
4.2 不那么显眼的死锁(方法间嵌套锁)
rust
impl State {
fn foo(&self) {
let mut guard = self.value.lock(); // 锁住
*guard += 1;
self.bar(); // 调用 bar
}
fn bar(&self) {
let mut guard = self.value.lock(); // 同一把锁,死锁!
if *guard > 10 { *guard = 0 }
}
}
foo 持有锁时调用 bar,bar 试图再次获取同一把锁,陷入永久等待。
GDB 的调用栈能清楚地展示这个问题:
arduino
#9 ... lock_api::mutex::Mutex::lock
#10 ... lox::State::bar (src/main.rs:19)
#11 ... lox::State::foo (src/main.rs:13)
#12 ... lox::main ()
4.3 重构避免死锁的常规手法
把 Mutex 推到类型外面,方法接受 &mut self(已经持有锁时才能调用):
rust
struct State {
value: u64, // 不再包在 Mutex 里
}
impl State {
fn foo(&mut self) {
self.value += 4;
self.bar(); // 安全:已经持有 &mut,不需要再加锁
}
fn bar(&mut self) {
if self.value > 10 { self.value = 0 }
}
}
fn main() {
let s: Mutex<State> = Default::default();
s.lock().foo(); // 在外部加锁一次
}
五、match + Mutex 的惊人组合
5.1 几段看似无害的代码
先看以下三段代码,它们都能正常运行:
写法 A:
rust
if s.lock().is_even() {
s.lock().increment();
}
写法 B:
rust
let is_even = s.lock().is_even();
if is_even {
s.lock().increment();
}
写法 C:
rust
let is_even = s.lock().is_even();
match is_even {
true => { s.lock().increment(); }
false => { println!("wasn't even"); }
}
以上三种写法都没有问题,运行正常。
5.2 其中一段会死锁------你猜到了吗?
写法 D:
rust
match s.lock().is_even() { // <-- 锁就在这里
true => {
s.lock().increment(); // <-- 试图再次加锁
}
false => {
println!("wasn't even");
}
}
arduino
$ cargo run --quiet
^C
死锁了。
写法 A 中,s.lock() 返回的临时 MutexGuard 在 if 条件求值后就立即释放------进入 if 体之前锁已经不在了。
写法 D 中,s.lock() 的临时值出现在 match 的被匹配表达式 (scrutinee)里,锁的生命周期被延长到了整个 match 块结束为止 。这是 Rust 的一条特殊规则:为了支持引用穿透,match 会将 scrutinee 中的临时值的生命周期延长。
5.3 到底发生了什么:match 的临时值生命周期延长
Rust 编译器实际上对 match 做了这样的变换:
rust
// 你写的
match n.lock().get() {
0 => "zero",
_ => "non-zero",
}
// 编译器实际做的
{
let tmp1 = n.lock(); // MutexGuard 被保存
let tmp2 = tmp1.get(); // 对 guard 的引用
match tmp2 {
0 => "zero",
_ => "non-zero",
}
// 到这里 tmp1(guard)才释放
}
这个设计本身是合理的------它让以下这种"引用穿透"的用法成为可能:
rust
let msg = match n.lock().get() {
0 => "zero",
_ => "non-zero",
};
如果不延长生命周期,从 lock() 产生的临时 guard 会立即释放,get() 返回的引用就悬空了。
但代价是:当 match arm 中又需要获取同一把锁时,就会死锁。
六、Async Rust 中的相同问题
6.1 async/await 基本工作原理
Async Rust 允许在单线程上并发运行多个任务:
rust
#[tokio::main(flavor = "current_thread")]
async fn main() {
join_all("abc".chars().map(|name| async move {
for _ in 0..5 {
sleep(Duration::from_millis(10)).await;
print!("{name}");
}
})).await;
}
// 输出:abcabcabcabcabc
当一个 Future .await 时,如果结果还没准备好,运行时就保存当前状态(包括所有局部变量),然后切换去运行其他任务。等到条件满足(比如计时器到期),再唤醒这个 Future 继续执行。
6.2 持有同步锁跨越 await 点导致死锁
看这段代码:
rust
async move {
for _ in 0..5 {
let mut guard = res.lock(); // 加锁
sleep(Duration::from_millis(10)).await; // yield 出去,但 guard 还在
guard.push(name);
}
}
问题在于:当 .await 让出执行权时,guard 作为局部变量被保存在 Future 的状态里------锁没有被释放。
如果另一个 Future 也想获取这把锁,它就会永远等待。而"等待"本身也是阻塞性的(parking_lot 的 Mutex 会直接阻塞线程),让 Future 1 永远不会被再次调度,从而死锁。
结论:不要在同步 Mutex guard 持有期间跨越 .await 点。
6.3 使用 tokio::sync::Mutex 解决
如果确实需要在异步代码中持有锁并等待,使用 Tokio 提供的异步 Mutex:
rust
use tokio::sync::Mutex;
async move {
for _ in 0..5 {
let mut guard = res.lock().await; // 异步加锁
sleep(Duration::from_millis(10)).await; // 安全:lock 是异步的
guard.push(name);
}
}
异步 Mutex 在无法立即获取锁时,会让出执行权(yield),而不是阻塞线程------这样运行时可以继续执行其他任务,直到锁可用时再唤醒这个 Future。
七、Clippy lint 的现状与不足
既然这是个常见陷阱,Clippy 应该能检测到吧?现状不太理想。
7.1 await_holding_lock lint
Clippy 确实有对应的 lint:clippy::await_holding_lock,但需要手动开启:
rust
#![warn(clippy::await_holding_lock)]
csharp
warning: this MutexGuard is held across an 'await' point.
Consider using an async-aware Mutex type or ensuring
the MutexGuard is dropped before calling await
它曾经在 correctness 分类下(默认 deny),但因为存在假阳性问题被降到了 pedantic 分类(默认不开启)。
7.2 假阳性问题
即使你已经在 .await 之前手动 drop 了 guard,lint 仍然会报警:
rust
let mut lock = m.lock().unwrap();
*lock += 1;
drop(lock); // 已经释放了
sleep(Duration::from_millis(10)).await; // 但 lint 还是报警
变通方案是用代码块限制作用域:
rust
{
let mut lock = m.lock().unwrap();
*lock += 1;
} // 在这里释放
sleep(Duration::from_millis(10)).await;
7.3 不支持 parking_lot
更糟糕的是,这个 lint 对 parking_lot::Mutex 完全无效:
rust
use parking_lot::Mutex; // 换成这个
let mut lock = m.lock(); // 注意:parking_lot 的 lock() 不需要 unwrap
sleep(Duration::from_millis(10)).await;
*lock += 1;
// $ cargo clippy
// Finished dev -- 没有任何警告!
尽管原始 PR 里明确提到要支持 parking_lot,实际上这块已经失效了。
7.4 无法检测闭包内部的情形
即使在启用了 #![deny(clippy::await_holding_lock)] 的情况下,lint 也无法检测到异步闭包/块内部的问题:
rust
join_all("abc".chars().map(|name| {
let res = &res;
async move {
for _ in 0..5 {
let mut guard = res.lock().unwrap(); // lint 看不到这里
sleep(Duration::from_millis(10)).await;
guard.push(name);
}
}
})).await;
// $ cargo clippy
// Finished dev -- 依然没有任何警告
八、作者真实踩坑:RwLock + match + async 的完美风暴
8.1 问题代码重现
作者因此损失了将近一周半的时间(当然也顺便重构了一堆代码)。真实的 bug 是以下几个因素的叠加:
rust
use parking_lot::RwLock;
#[tokio::main(worker_threads = 2)]
async fn main() {
let state: Arc<RwLock<State>> = Default::default();
// 后台任务:持续更新
tokio::spawn({
let state = state.clone();
async move {
loop {
state.write().update();
sleep(Duration::from_millis(1)).await;
}
}
});
// 主循环:读取并处理
for _ in 0..10 {
match state.read().foo() { // 读锁在这里,但会持续到 match 结束!
true => {
sleep(Duration::from_millis(1)).await; // 持有读锁跨过 await!
println!("bar = {}", state.read().bar()); // 又尝试获取读锁
}
false => {
println!("it's false!");
}
}
}
}
这段代码有时正常运行,有时死锁------取决于线程调度时机,极难稳定复现。
8.2 GDB 调试分析
当程序死锁时,GDB 显示:
less
Thread 3 (tokio-runtime-w):
...
#9 RwLock::write <- 在等待写锁
#10 lox::main (src/main.rs:34) <- 后台更新任务
Thread 1 (lox):
...
#9 RwLock::read <- 在等待读锁
#10 lox::main (src/main.rs:45) <- 主循环
死锁的模式是典型的 RWR(读-写-读)交错:
- 主任务持有一个读锁 (来自
match state.read().foo(),会持续到整个match结束) - 后台任务试图获取写锁 (
state.write()),被阻塞------因为有读锁在 parking_lot的RwLock实现中,写锁等待期间会阻止新的读锁获取(防止写饥饿)- 主任务中的
state.read().bar()试图获取第二个读锁,也被阻塞 - 经典三方死锁:读 → 等写 → 等读 → 形成环路
8.3 诊断困难的多重原因
作者面临的困境层层叠加:
原因一:没意识到 match scrutinee 会持有锁
不知道在 match state.read().foo() 中,读锁会被持有直到整个 match 块结束。
原因二:依赖 Clippy lint,但 lint 已经失效
知道"不要持有锁跨越 await 点"这个规则,也知道有 await_holding_lock lint,但是:
- 这个 lint 需要手动开启(而不是默认报错)
- 它对
parking_lot无效
原因三:parking_lot 的死锁检测器也帮不上忙
parking_lot 有一个实验性的 deadlock_detection feature:
toml
parking_lot = { version = "0.12.0", features = ["deadlock_detection"] }
但作者无法启用它------因为依赖树中某个 crate 使用了 parking_lot 的 send_guard feature,而这两个 feature 不兼容。更糟的是,没有简单的方法找出是哪个依赖开启了 send_guard。
(文章后记:cargo tree -i parking_lot -e features 可以查到------但这是文章发布后才知道的。)
即使能开启死锁检测,实验证明它也没有如预期地报告问题。
8.4 最终修复方式
修复非常简单------提前把 lock 的结果存到变量里,让 guard 在 match 之前就释放:
rust
for _ in 0..10 {
let res = state.read().foo(); // guard 在这行结束后立即释放
match res { // 此时锁已经不在了
true => {
sleep(Duration::from_millis(1)).await;
println!("bar = {}", state.read().bar()); // 安全
}
false => {
println!("it's false!");
}
}
}
本质上,写法 D(match s.lock().method())和写法 C(let r = s.lock().method(); match r)之间有天壤之别。
九、社区反应与未来改进方向
作者在 Twitter 上分享了这个问题后,Rust 社区做出了积极响应:
大家普遍认为,以下代码会导致死锁是"最糟糕方式的惊喜":
rust
match mutex.lock().foo() {
true => { mutex.lock().bar(); }
false => {}
}
随之而来的行动:
正在进行的工作:
- 有人开了一个 Rust issue,提议为"match scrutinee 中临时值的意外延迟 drop"添加 lint,并有资深开发者提供 mentorship
- 有人提了 Clippy PR 修复
await_holding_lock对parking_lot支持的问题 - 这个问题其实早在 2016 和 2017 就被提出过,现在重新引起了关注
更长远的方案:
理论上可以在 Rust 2024 edition 中修改语义(不破坏旧版本兼容性),也可以通过 #[must_not_suspend] 属性让编译器直接拒绝此类代码:
rust
#![feature(must_not_suspend)]
async fn do_stuff(m: &Mutex<u64>) {
let mut guard = m.lock();
sleep(Duration::from_millis(10)).await; // 编译错误!
*guard += 1;
}
对于当前实际可用的改进,作者认为最重要的两点是:
- 修复
await_holding_lock对parking_lot的支持,消除假阳性,重新提升到correctness级别 - 为
match scrutinee中的意外临时值生命周期延长添加独立的 lint
总结
这篇文章揭示了一个 Rust 中相当隐蔽的行为:match 表达式的 scrutinee 中产生的临时值,生命周期会被延长到整个 match 块结束。
在普通代码中,这个特性使引用穿透变得优雅自然。但当临时值是一个 MutexGuard 时,它就变成了隐患:如果在任何一个 match arm 内再次尝试获取同一把锁,就会死锁。
这个陷阱在以下条件叠加时尤为难以发现:
- 使用
parking_lot(而不是标准库的Mutex) - 在异步代码中(持有同步锁跨越 await 点)
- 使用
RwLock(死锁表现为非确定性,取决于调度时机) - Clippy lint 因各种原因未能给出警告
记住这一条规则:不要在 match 的 scrutinee 里内联地调用可能产生有 Drop side effect 的临时值的方法,除非你清楚地知道该临时值会在 match 结束后才被释放。 最安全的做法是,先把结果存到一个具名变量里,再交给 match。
涉及的主要工具和 crate
parking_lot(Mutex / RwLock)、tokio(异步运行时 / sync::Mutex)、futures(join_all)、rust-analyzer(IDE 支持)、clippy(await_holding_lock lint)、GDB/rust-gdb(调试工具)