Skip to content
rusthacks.dev
Concurrency

Tokio Mutex Versus Std Mutex and When Each Is Correct

Use std::sync::Mutex when the lock is never held across an await and tokio::sync::Mutex when it must be. The std one is faster and is the default choice.

8 min read
Summarize in AI
Flat illustration for the post “Tokio Mutex Versus Std Mutex and When Each Is Correct”, composed around perf.

Async Rust has two mutexes with the same name and different jobs. std::sync::Mutex is a normal operating-system lock: acquiring it can block the thread. tokio::sync::Mutex is an async lock: acquiring it suspends the task and lets the thread run something else. The Tokio documentation itself says to prefer the std one in most cases, which surprises people who assume "async code needs an async mutex". The deciding question is not "is my code async" but "do I need to keep the lock held across an .await". This post shows the cases on each side, the measured price of the async lock, fairness differences, and the third option (parking_lot) for completeness. Everything compiled on Rust 1.99, edition 2024, with tokio 1.

The rule

Use std::sync::Mutex when the critical section is short and contains no .await. Use tokio::sync::Mutex when you must hold the lock while awaiting something. If you find yourself wanting the second, check first whether you can restructure so you do not need to (copy the data out, release the lock, then await), or whether the state belongs to an actor task, as in sharing state between tasks. The reasons come from what happens at contention.

What blocking means for each

When task A holds a std mutex and task B on the same worker thread calls lock(), B blocks the thread. If A can only make progress when that thread runs (because A is waiting for a timer or an I/O event handled by the same worker), neither can proceed: a deadlock, with no panic and no message. The compiler stops you in the common case, because a std guard is not Send and tokio::spawn requires its future to be:

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

static STATE: Mutex<Vec<u32>> = Mutex::new(Vec::new());

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let holder = tokio::spawn(async {
        let mut guard = STATE.lock().unwrap();
        guard.push(1);
        sleep(Duration::from_millis(50)).await;
        guard.push(2);
    });
    holder.await.unwrap();
}
Text
error: future cannot be sent between threads safely
   --> src/main.rs:9:18
    |
  9 |       let holder = tokio::spawn(async {
    |  __________________^
 10 | |         let mut guard = STATE.lock().unwrap();
 11 | |         guard.push(1);
 12 | |         sleep(Duration::from_millis(50)).await;
 13 | |         guard.push(2);
 14 | |     });
    | |______^ future created by async block is not `Send`
    |
    = help: within `{async block@src/main.rs:9:31: 9:36}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, Vec<u32>>`
note: future is not `Send` as this value is used across an await
   --> src/main.rs:12:42
    |
 10 |         let mut guard = STATE.lock().unwrap();
    |             --------- has type `std::sync::MutexGuard<'_, Vec<u32>>` which is not `Send`
 11 |         guard.push(1);
 12 |         sleep(Duration::from_millis(50)).await;
    |                                          ^^^^^ await occurs here, with `mut guard` 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 error is the same on a current-thread runtime, because spawn does not know which runtime flavor will run the task. The message points at the guard, at the await where it is still alive, and at the Send bound on spawn. To see what the lock looks like from outside while a task sleeps with the guard held, run the same code on a LocalSet, which accepts futures that are not Send, and probe it with try_lock so that the program cannot hang:

rust
use std::sync::Mutex;
use std::time::Duration;
use tokio::task::LocalSet;
use tokio::time::sleep;

static STATE: Mutex<Vec<u32>> = Mutex::new(Vec::new());

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let local = LocalSet::new();
    local
        .run_until(async {
            let holder = tokio::task::spawn_local(async {
                let mut guard = STATE.lock().unwrap();
                guard.push(1);
                sleep(Duration::from_millis(50)).await;
                guard.push(2);
            });

            sleep(Duration::from_millis(10)).await;
            match STATE.try_lock() {
                Ok(_) => println!("second task: got the lock"),
                Err(e) => println!("second task: lock is held by a sleeping task ({e})"),
            }
            holder.await.unwrap();
            println!("final: {:?}", STATE.lock().unwrap());
        })
        .await;
}
Text
second task: lock is held by a sleeping task (try_lock failed because the operation would block)
final: [1, 2]

The first task kept the guard across a sleep, so for 50 ms the lock was unavailable even though nobody was using it. A plain lock() in the second task, instead of try_lock, would have frozen the whole program: the first task could never wake up to release it. On a multi-thread runtime a second worker blocks instead, and you lose throughput rather than liveness, which is why the bug survives light testing. The error and its fixes are developed in mutex guards across await.

When you do need the async mutex

The legitimate case is a resource that is used through async calls and must not be interleaved: a single connection that cannot run two requests at once, a file writer, a protocol state machine. The lock guards the sequence, and the sequence includes awaits:

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

struct Connection {
    log: Vec<String>,
}

impl Connection {
    async fn request(&mut self, who: &str) {
        self.log.push(format!("{who}: sent"));
        sleep(Duration::from_millis(100)).await;
        self.log.push(format!("{who}: got reply"));
    }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let conn = Arc::new(Mutex::new(Connection { log: Vec::new() }));
    let t = Instant::now();

    let mut handles = Vec::new();
    for who in ["a", "b", "c"] {
        let conn = conn.clone();
        handles.push(tokio::spawn(async move {
            conn.lock().await.request(who).await;
        }));
    }
    for h in handles {
        h.await.unwrap();
    }

    for line in &conn.lock().await.log {
        println!("{line}");
    }
    println!("total {:?}", t.elapsed());
}
Text
a: sent
a: got reply
b: sent
b: got reply
c: sent
c: got reply
total 300ms

The three requests ran one at a time, each completing before the next began (300 ms in all), and the log shows no interleaving. A std mutex could not do this, because holding its guard across the sleep either would not compile on a multi-thread runtime or would block a worker. Note that the guard lives for the whole request call: that is the point of the lock, and also the cost, since it serialises everything behind it. If the connection supports pipelining, a pool of connections or a task that owns the connection and queues requests gives more throughput.

The price of the async lock

The async mutex does real work to be awaitable: it keeps a wait queue of tasks and wakes them in order. On an uncontended lock the extra cost is small; under contention it adds a task switch per hand-off. Measured with 8 tasks making 250,000 increments each to a shared counter on an Apple M1 Max in a release build (best of three), the std mutex took 40 ms and the Tokio mutex 222 ms: 20 ns versus 111 ns per locked increment, a factor of 5.5. That is a microbenchmark of the worst case (a lock with nothing in it, hammered), and in a real handler where the critical section does anything meaningful the difference vanishes. It is a reason not to reach for the async mutex by habit, not a reason to avoid it where you need it.

Fairness and ordering

The Tokio mutex is fair: waiting tasks acquire it in the order they asked (first in, first out), so a busy task cannot starve the rest. The std mutex makes no such promise. On Linux it is a futex-based lock that can let the thread that just released it take it again before a waiter wakes ("barging"), which is faster overall and can starve a waiter under heavy load. If you need ordering guarantees, such as requests being served in arrival order, that is a reason for the async mutex; if you need raw throughput on short sections, it is a reason against it.

Making the std mutex work with async code

The standard technique for state with short updates is to confine the guard to a block so it is dropped before any await:

rust
use std::collections::HashMap;
use std::sync::Mutex;
use tokio::time::{sleep, Duration};

struct Cache {
    entries: Mutex<HashMap<u32, String>>,
}

impl Cache {
    async fn get_or_load(&self, id: u32) -> String {
        if let Some(hit) = self.entries.lock().unwrap().get(&id).cloned() {
            return hit;
        }

        sleep(Duration::from_millis(20)).await;
        let loaded = format!("record {id}");

        self.entries.lock().unwrap().entry(id).or_insert_with(|| loaded.clone());
        loaded
    }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
    let cache = Cache { entries: Mutex::new(HashMap::new()) };
    println!("{}", cache.get_or_load(7).await);
    println!("{}", cache.get_or_load(7).await);
    println!("cached entries: {}", cache.entries.lock().unwrap().len());
}
Text
record 7
record 7
cached entries: 1

Each lock is taken and released within one statement, so no guard exists at the await. The cost of this pattern is a small race: two tasks missing the same key both load it, and the second or_insert_with keeps the first result. For a cache, loading twice is usually acceptable; if it is not, coalesce the loads, as in cache stampede coalescing.

parking_lot and the lock-free options

parking_lot::Mutex is a drop-in replacement for the std mutex with a smaller footprint (one byte), no poisoning and, historically, better performance under contention. Modern std mutexes narrowed most of the gap, so choose it for the no-poisoning API or the extras (try_lock_for, fair unlock), not for speed. For read-mostly data, RwLock lets many readers in at once; under write contention it is no faster than a mutex, as the measurement above shows (218 ms for the write path). For one number, an atomic is simpler than either.

Decision table

SituationUse
Short update, no await insidestd::sync::Mutex
Must hold across .await, state is a resource used through async callstokio::sync::Mutex (or an owning task)
Long-lived guard shared in a structlock_owned() on an Arc<tokio::sync::Mutex<T>> returns a guard with no lifetime
You want FIFO ordering of waiterstokio::sync::Mutex
A single counter or flagAn atomic
Called from sync code and async codestd mutex; the async one has blocking_lock but that panics inside the runtime
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.