Skip to content
rusthacks.dev
Concurrency

Holding a Mutex Guard Across an Await in Rust and the Fix

A MutexGuard held across an await can make a task non-Send or deadlock a worker. See the compiler error, the deadlock it allows and three fixes.

8 min read
Summarize in AI
Flat illustration for the post “Holding a Mutex Guard Across an Await in Rust and the Fix”, composed around enums.

The error "future cannot be sent between threads safely" has many causes, and the most common one in real Tokio code is a std::sync::MutexGuard that is still alive when the function reaches an .await. It is worth its own post because the obvious fix does not work (calling drop(guard) before the await does not satisfy the compiler), the lint that catches the unspawned version is a warning many projects have turned off, and the bug that the error is protecting you from is a freeze that no test reproduces. This post shows the error, the fixes that compile, the fix that does not, the clippy warning and the case where holding a lock across an await is correct. Everything compiled on Rust 1.99, edition 2024, with tokio 1.

The error

rust
use std::sync::{Arc, Mutex};
use std::time::Duration;

#[tokio::main]
async fn main() {
    let balance = Arc::new(Mutex::new(100u32));
    let b = balance.clone();

    let task = tokio::spawn(async move {
        let mut g = b.lock().unwrap();
        *g -= 30;
        tokio::time::sleep(Duration::from_millis(10)).await;
        *g -= 20;
    });

    task.await.unwrap();
    println!("{}", balance.lock().unwrap());
}
Text
error: future cannot be sent between threads safely
   --> src/main.rs:9:16
    |
  9 |       let task = tokio::spawn(async move {
    |  ________________^
 10 | |         let mut g = b.lock().unwrap();
 11 | |         *g -= 30;
 12 | |         tokio::time::sleep(Duration::from_millis(10)).await;
 13 | |         *g -= 20;
 14 | |     });
    | |______^ future created by async block is not `Send`
    |
    = help: within `{async block@src/main.rs:9:29: 9:39}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, u32>`
note: future is not `Send` as this value is used across an await
   --> src/main.rs:12:55
    |
 10 |         let mut g = b.lock().unwrap();
    |             ----- has type `std::sync::MutexGuard<'_, u32>` which is not `Send`
 11 |         *g -= 30;
 12 |         tokio::time::sleep(Duration::from_millis(10)).await;
    |                                                       ^^^^^ await occurs here, with `mut g` maybe used later
note: required by a bound in `tokio::spawn`
   --> tokio-1.54.1/src/task/spawn.rs:176:21
    |
174 |     pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
    |            ----- required by a bound in this function
175 |     where
176 |         F: Future + Send + 'static,
    |                     ^^^^ required by this bound in `spawn`

Read it from the bottom. The last note is the requirement: tokio::spawn demands a Send future because the task may resume on a different worker thread. The note above it names the culprit: the guard g has a type that is not Send and is "used across an await" at the line of the sleep. A std mutex guard is deliberately not Send: on some platforms a lock must be released by the thread that took it, so moving a live guard to another thread would be unsound. The compiler is not guessing; it is protecting you from unlocking from the wrong thread, and as a side effect from the freeze described below.

What the compiler is protecting you from

If it were allowed, two things could happen while the task sleeps with the guard held. Any other task calling lock() blocks its worker thread until this one wakes up, which on a single-thread runtime means the lock holder can never run again: a deadlock where the two are waiting for each other and no thread is free to break the tie. And on a multi-thread runtime you would lose a worker per waiter, a throughput collapse with no error. Both are hard to reproduce in tests, which tend to have one task. The scenario is walked through step by step in which mutex to use.

The fix that does not work: drop(guard)

The natural repair is to release the guard explicitly before the await. The compiler still rejects it:

rust
use std::sync::{Arc, Mutex};
use std::time::Duration;

#[tokio::main]
async fn main() {
    let balance = Arc::new(Mutex::new(100u32));
    let b = balance.clone();

    let task = tokio::spawn(async move {
        let mut g = b.lock().unwrap();
        *g -= 30;
        drop(g);
        tokio::time::sleep(Duration::from_millis(10)).await;
        *b.lock().unwrap() -= 20;
    });

    task.await.unwrap();
    println!("{}", balance.lock().unwrap());
}
Text
error: future cannot be sent between threads safely
   --> src/main.rs:9:16
    |
  9 |       let task = tokio::spawn(async move {
    |  ________________^
 10 | |         let mut g = b.lock().unwrap();
 11 | |         *g -= 30;
 12 | |         drop(g);
 13 | |         tokio::time::sleep(Duration::from_millis(10)).await;
 14 | |         *b.lock().unwrap() -= 20;
 15 | |     });
    | |______^ future created by async block is not `Send`
    |
    = help: within `{async block@src/main.rs:9:29: 9:39}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, u32>`
note: future is not `Send` as this value is used across an await
   --> src/main.rs:13:55
    |
 10 |         let mut g = b.lock().unwrap();
    |             ----- has type `std::sync::MutexGuard<'_, u32>` which is not `Send`
...
 13 |         tokio::time::sleep(Duration::from_millis(10)).await;
    |                                                       ^^^^^ await occurs here, with `mut g` maybe used later
note: required by a bound in `tokio::spawn`
   --> tokio-1.54.1/src/task/spawn.rs:176:21
    |
174 |     pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
    |            ----- required by a bound in this function
175 |     where
176 |         F: Future + Send + 'static,
    |                     ^^^^ required by this bound in `spawn`

The analysis that decides whether a value is "used across an await" is based on scopes, not on moves: a variable counts as live until the end of its enclosing block, even after it has been moved into drop. This limitation has been known for years and the message offers no hint about it. The fix is to make the scope end, with a block.

Fix 1: end the guard's scope with a block

rust
use std::sync::{Arc, Mutex};
use std::time::Duration;

#[tokio::main]
async fn main() {
    let balance = Arc::new(Mutex::new(100u32));
    let b = balance.clone();

    let task = tokio::spawn(async move {
        {
            let mut g = b.lock().unwrap();
            *g -= 30;
        }
        tokio::time::sleep(Duration::from_millis(10)).await;
        {
            let mut g = b.lock().unwrap();
            *g -= 20;
        }
    });

    task.await.unwrap();
    println!("balance: {}", balance.lock().unwrap());
}
Text
balance: 50

Now each guard lives inside its own braces and is gone before the await. The state between the two updates is visible to other tasks, which is the real consequence: if the two updates must happen as one atomic step, this fix is wrong, and you need the next ones. Wrapping the lock access in small methods on a struct (fn debit(&self, n: u32)) makes the scoping automatic, since a guard created inside a non-async method cannot outlive it.

Fix 2: copy out what you need, lock again to write back

When the await is in the middle of a computation that needs the data, read what you need into a local, release the lock, do the slow work, and take the lock again to write:

rust
use std::sync::{Arc, Mutex};
use std::time::Duration;

async fn convert(amount: u32) -> u32 {
    tokio::time::sleep(Duration::from_millis(10)).await;
    amount * 2
}

#[tokio::main]
async fn main() {
    let balance = Arc::new(Mutex::new(100u32));
    let b = balance.clone();

    let task = tokio::spawn(async move {
        let snapshot = *b.lock().unwrap();
        let converted = convert(snapshot).await;
        *b.lock().unwrap() = converted;
    });

    task.await.unwrap();
    println!("balance: {}", balance.lock().unwrap());
}
Text
balance: 200

The temporary guard in *b.lock().unwrap() is dropped at the end of the statement, so none is alive at the await. The price is a time-of-check to time-of-use gap: another task may have changed the balance between the read and the write. For a conversion that is idempotent over a snapshot, that is fine; for a debit it is a lost update. Version numbers or compare-and-swap loops close the gap when needed.

Fix 3: hold the lock on purpose, with the async mutex

When the sequence really must be atomic, with I/O in the middle, use tokio::sync::Mutex, whose guard is Send and whose lock() suspends the task instead of blocking the thread:

rust
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;

#[tokio::main]
async fn main() {
    let balance = Arc::new(Mutex::new(100u32));
    let b = balance.clone();

    let task = tokio::spawn(async move {
        let mut g = b.lock().await;
        *g -= 30;
        tokio::time::sleep(Duration::from_millis(10)).await;
        *g -= 20;
    });

    task.await.unwrap();
    println!("balance: {}", balance.lock().await);
}
Text
balance: 50

The same code as the failing version, with the mutex swapped and two .await additions, now compiles and keeps the debit and the second step atomic. Every other task wanting the lock waits in line for the full 10 ms, which is the cost of correctness here. If that is too slow, the state belongs to one owner task, as in sharing state between tasks.

A quick way to choose among the three: ask whether another task could legitimately observe the state in the middle. If yes, the first or second fix is fine and cheaper. If the intermediate state would be a bug (money debited but not yet converted), you need the lock to cover the await, and with it a decision about how long other tasks may wait. In code review, the question to ask of any guard that is alive across an await, in either mutex, is "what is the longest this await can take?", because that is the longest every other task may be blocked behind it.

The clippy lint and the case with no spawn

The compiler error only appears when a Send bound is demanded. A function awaited directly from a current-thread block_on compiles with the guard held, and then deadlocks when a second task contends. Clippy has lints for exactly this, await_holding_lock and await_holding_refcell_ref, and they are on by default as warnings:

Text
warning: this `MutexGuard` is held across an await point
 --> src/main.rs:5:9
  |
5 |     let mut g = m.lock().unwrap();
  |         ^^^^^
  |
  = help: consider using an async-aware `Mutex` type or ensuring the `MutexGuard` is dropped before calling `await`
note: these are all the await points this lock is held through
 --> src/main.rs:7:50
  |
7 |     tokio::time::sleep(Duration::from_millis(1)).await;
  |                                                  ^^^^^
  = note: `#[warn(clippy::await_holding_lock)]` on by default

Run cargo clippy -- -D warnings in CI and this warning cannot reach production unnoticed; the CI setup is in continuous integration for Rust. The same family catches a RefCell borrow held across an await, which fails at run time with a "already borrowed" panic instead of a deadlock.

Other values with the same problem

The error generalises. Any non-Send value alive across an await has the same effect: an Rc, a RefCell borrow (Ref), a raw pointer, a thread-local reference, a std::cell::Cell held by reference. The remedies are the same: shorten the scope, restructure so the value is created after the await, or switch to a Send equivalent (Arc for Rc). The general guide to reading the whole family of these messages is future cannot be sent between threads safely, and the underlying traits are in Send and Sync.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.