一个让我调试一周的 Rust match 陷阱

本文是对 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`

如果类型是纯数据(没有堆内存),可以派生 CloneCopy,让它在传递时自动复制:

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 对此完全透明:无论 protProtection 还是 &Protectionmatch 的写法可以保持不变。在匹配时,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> 实现了 DerefDerefMut,让它像智能指针一样直接操作内部数据。

其生命周期与 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 持有锁时调用 barbar 试图再次获取同一把锁,陷入永久等待。

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() 返回的临时 MutexGuardif 条件求值后就立即释放------进入 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(读-写-读)交错

  1. 主任务持有一个读锁 (来自 match state.read().foo(),会持续到整个 match 结束)
  2. 后台任务试图获取写锁state.write()),被阻塞------因为有读锁在
  3. parking_lotRwLock 实现中,写锁等待期间会阻止新的读锁获取(防止写饥饿)
  4. 主任务中的 state.read().bar() 试图获取第二个读锁,也被阻塞
  5. 经典三方死锁:读 → 等写 → 等读 → 形成环路

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_lotsend_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_lockparking_lot 支持的问题
  • 这个问题其实早在 20162017 就被提出过,现在重新引起了关注

更长远的方案:

理论上可以在 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;
}

对于当前实际可用的改进,作者认为最重要的两点是:

  1. 修复 await_holding_lockparking_lot 的支持,消除假阳性,重新提升到 correctness 级别
  2. 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(调试工具)

相关推荐
一只大袋鼠10 小时前
SpringBoot 初学阶段知识点汇总(一)
spring boot·笔记·后端
Rust研习社10 小时前
Rust 官方拟定 LLM 政策,防止 LLM 污染开源社区?
开发语言·后端·ai·rust·开源
无风听海10 小时前
ASP.NET Core Minimal API 深度解析
后端·asp.net
IT_陈寒10 小时前
Java的finally块竟然不是你想的那个finally!
前端·人工智能·后端
zb2006412010 小时前
Laravel4.x核心特性全解析
spring boot·后端·php·laravel
techdashen11 小时前
在 Async Rust 中实现请求合并(Request Coalescing)
开发语言·后端·rust
lzp079111 小时前
C#如何优雅处理引用类型的深拷贝(贰)
spring boot·后端·ui
Mr.Java.11 小时前
Spring AI MCP Server分布式翻车现场:Streamable协议的甜蜜与危险,以及无状态救赎
java·后端·spring·ai·负载均衡
夕除11 小时前
spring boot 11
java·spring boot·后端