Blog / Rust

  • rust
  • mutex
  • concurrency
  • temporaries
  • deadlock
  • debugging

Why Your Rust Mutex Guard Lives Longer Than You Think

This looks like it takes a job off a queue and then runs it with the lock released. It doesn't. It runs the job with the lock still held, and if the job touches the same mutex, the thread waits on itself forever.

use std::collections::VecDeque;
use std::sync::Mutex;

struct State {
    queue: Mutex<VecDeque<u32>>,
}

fn run(state: &State, job: u32) {
    // Jobs are allowed to enqueue follow-up work.
    state.queue.lock().unwrap().push_back(job + 1);
}

fn worker(state: &State) {
    match state.queue.lock().unwrap().pop_front() {
        Some(job) => run(state, job), // second lock() on the same thread
        None => {}
    }
}

pop_front() returns an owned Option<u32>. Nothing borrowed from the guard survives into the match arms. The guard stays alive anyway, because of a rule about temporaries rather than anything the borrow checker works out.

The guard is a temporary, and temporaries live to the end of the statement

state.queue.lock().unwrap() creates a MutexGuard that is never bound to a name. Rust drops such a temporary at the end of the enclosing statement. For a plain let, that is the semicolon, which is what you want.

let next = state.queue.lock().unwrap().pop_front(); // guard dropped here
match next {
    Some(job) => run(state, job), // lock is free
    None => {}
}

But a match is one big statement. The scrutinee's temporaries survive until the closing brace of the whole match, arms included. Splitting the lock into its own let is the whole fix.

Quick detour: why doesn't the compiler notice the arms don't borrow?

Because the rule is syntactic, not analytical. A scrutinee can be a place expression, so match *m.lock().unwrap() { ref x => ... } binds a reference straight into the guarded data. The temporary has to outlive the arms in that case, and the language picks one rule for every case rather than inspecting each. It is a bit daft that pop_front() pays for it, but it is predictable once you know it.

The same trap in if let and while let

The while let version is the one that hurts most in real code, because it looks like the textbook way to drain a queue:

while let Some(job) = state.queue.lock().unwrap().pop_front() {
    run(state, job); // guard from the condition is still alive here
}

Every iteration re-evaluates the scrutinee, so the lock is taken, held across the body, and dropped at the end of the iteration. Other threads can only get in between iterations, and a single-threaded re-lock in the body deadlocks. The safe drain loop is a plain loop with a let and a break.

loop {
    let Some(job) = state.queue.lock().unwrap().pop_front() else {
        break;
    };
    run(state, job); // lock released at the end of the let statement
}

let ... else is a statement, so the guard is gone by the time the body runs. That is why this shape works where while let does not.

What edition 2024 changed, and what it didn't

Rust 2024 changed two temporary-scope rules, and it is worth knowing which is which:

  • if let: temporaries in the scrutinee are now dropped before the else block runs. In earlier editions they lived through it.
  • Tail expressions: temporaries in a block's final expression are dropped before the block's local variables, not after.
  • match and while let: unchanged. The guard still lives for the whole match, or for the whole loop body.

So under 2024, an if let Some(j) = q.lock().unwrap().pop_front() { ... } else { q.lock() ... } no longer deadlocks in the else branch. The then branch still holds the lock. Check the edition in Cargo.toml before deciding which of your existing code is affected; the 2024 migration also has an if_let_rescope lint that flags places where behaviour would shift.

The tail-expression change fixes a different annoyance. Something like let m = Mutex::new(vec![1]); m.lock().unwrap().len() as the last line of a function used to fail with a "does not live long enough" error, because the guard outlived m. That is the shape the edition guide uses to motivate the change (with RefCell), and the same reasoning applies to a mutex.

Named guards last until the end of the scope, full stop

If you do bind the guard, it lives until the end of its scope, and non-lexical lifetimes do not shorten it. The reason is that MutexGuard has a Drop implementation, so the drop at scope end counts as a use.

let mut q = state.queue.lock().unwrap();
q.push_back(1);
expensive_network_call(); // still holding the lock

Two ways out. Either put the guarded part in an inner block, or call drop(q) explicitly. The block is usually nicer, because it makes the critical section visible in the indentation and a later edit can't quietly extend it.

{
    let mut q = state.queue.lock().unwrap();
    q.push_back(1);
} // guard dropped here
expensive_network_call();

Across an await, it gets worse

A std::sync::MutexGuard held over an .await stays locked while the task is suspended. On a multi-threaded runtime, tokio::spawn will usually refuse to compile it, since the guard is not Send. On a single-threaded executor it compiles happily and can deadlock when a second task on the same thread tries to lock.

The same scrutinee rule applies here too: match m.lock().unwrap().get(..) { Some(x) => foo(x).await, ... } holds the guard over the await. Clippy has await_holding_lock for the bound-guard case. There is also significant_drop_in_scrutinee, aimed at the match case, but it sits in the nursery group, so you have to switch it on.

A habit that avoids most of this

  • Never lock inside a match, if let or while let head. Lock in a let, copy or take what you need out, and let the guard die at the semicolon.
  • If you need the data to outlive the lock, return an owned value (pop_front(), .clone(), mem::take) rather than a reference.
  • Keep critical sections in a small block or a small method, so the scope you can see is the scope the lock has.

A second lock on a std::sync::Mutex from the thread that already holds it is documented as unspecified: it might deadlock or panic. Don't rely on either outcome; treat it as a bug the compiler cannot catch for you.