Rust Concurrency Best Practices: Never Await While Holding a Mutex
Rust's compiler won't catch async mutex deadlocks. A former Cloudflare engineer's battle-tested checklist shows what to fix before they find you in production.

The Bug That Compiles Clean and Ships to Production
You refactor a synchronous function to async because you need to call another async method inside it. Tests pass. CI is green. You ship. Then, under real production load, your service starts hanging intermittently. Connections time out. The runtime appears frozen. You have just encountered the most deceptive class of bug in async Rust: holding a mutex lock across an `.await` point.
This scenario is not hypothetical. A seemingly innocent change, making a function async, is a documented real-world cause of intermittent deadlocks in concurrent systems. DashMap, one of the most-downloaded concurrent hash-map crates in the Rust ecosystem, can deadlock when a `DashMap Ref` is held across an `.await` point. The compiler says nothing. Clippy stays quiet. The bug ships.
Fearless Concurrency Has an Asterisk
Rust earned its ninth consecutive title as the most admired programming language in the 2024 Stack Overflow Developer Survey. Over 65,000 developers across 185 countries responded in May 2024, and 83% said they want to keep using it. The language's foundational appeal is "fearless concurrency," a term introduced in a Rust Blog post on April 10, 2015, which promised that Rust's ownership and type system would prevent entire classes of concurrency bugs at compile time.
That promise holds, up to a point. The compiler enforces that you cannot access a `Mutex<T>`'s inner value without calling `.lock()`, and it prevents data races at the type level. But as async programming patterns have become the norm, a new category of subtle failure has emerged that sits outside what the type system can statically verify. Viacheslav Biriukov, a former Cloudflare engineer who writes at biriukov.dev and posts on X as @brk0v, has documented these patterns and codified a set of rules that function as a pre-flight checklist for anyone writing concurrent async Rust.
Rule 1: Never `.await` While Holding a Mutex Lock
This is the cardinal rule, and the consequences of breaking it are severe. Tokio's official documentation is direct: "A synchronous mutex will block the current thread when waiting to acquire the lock. This, in turn, will block other tasks from processing." Because Tokio's async executor runs many tasks on a small pool of threads, a single blocked thread can starve the entire runtime. Unrelated tasks stop making progress. The service hangs.
The failure pattern is easy to write accidentally:
// BAD: lock is held across the .await point let mut data = shared.lock().unwrap(); data.value = fetch_from_db().await; // runtime can stall here
The fix is to complete all async work before acquiring the lock, then hold it only for the synchronous update:
// BETTER: async work finishes first, then lock briefly let new_value = fetch_from_db().await; let mut data = shared.lock().unwrap(); data.value = new_value; // guard drops immediately, no .await in scope
Narrow the lock's scope to synchronous operations only. Every `.await` inside a lock guard's lifetime is a potential runtime freeze.
Rule 2: Know Which Mutex to Reach For
Reaching for `tokio::sync::Mutex` might seem like the obvious fix for async code, but Tokio's documentation specifically cautions against it as a performance solution: switching to it "will usually not help with performance as the asynchronous mutex uses a synchronous mutex internally." Tokio's async mutex is designed primarily for shared mutable access to I/O resources such as database connections. For protecting plain data, a `std::sync::Mutex` or `parking_lot::Mutex`, held only during synchronous operations, is both more appropriate and more performant.
Rule 3: Wrap Shared State in `Arc<Mutex<T>>`
The canonical Rust pattern for sharing mutable state across threads and tasks is `Arc<Mutex<T>>`. `Arc` (Atomic Reference Counting) is a thread-safe smart pointer enabling multiple owners; `Mutex` enforces exclusive mutation. The type system reinforces this automatically: because the value is typed as `Mutex<T>` rather than bare `T`, the compiler requires `.lock()` before any access. You cannot accidentally read or write without acquiring the lock first.
// Standard shared-state pattern let shared = Arc::new(Mutex::new(AppState::default())); let clone = Arc::clone(&shared);
tokio::spawn(async move { let mut state = clone.lock().unwrap(); state.counter += 1; // guard drops here — no .await follows });

Rule 4: Centralize Your Locks, Don't Scatter Them
Scattered `.lock()` calls across a codebase are a correctness hazard that compounds over time. Each call site is an opportunity to hold a lock too long, to re-acquire a lock already held on the same thread, or to introduce a lock-ordering inconsistency that only manifests under load. Biriukov's guidance is to centralize locking: encapsulate shared state in a type whose methods acquire the lock internally, expose a clean API, and prevent callers from ever touching the raw `Mutex` directly.
// BAD: raw lock exposed everywhere pub shared: Arc<Mutex<AppState>>,
// BETTER: encapsulated behind a controlled interface impl StateManager { pub fn increment_counter(&self) { let mut s = self.inner.lock().unwrap(); s.counter += 1; } }
Rule 5: Mutexes Are Not Re-entrant
Rust's `Mutex` is not re-entrant. If a thread already holds a lock and attempts to acquire the same lock a second time, the result is a deadlock, not a second granted access. The type system does not prevent this. The risk appears most often in refactored code where a public method calls a private method and both paths attempt to lock the same mutex, or when a callback is invoked while a lock is already held.
// DANGEROUS: re-entrant locking deadlocks fn process(&self) { let _guard = self.data.lock().unwrap(); self.helper(); // if helper() also calls self.data.lock() → deadlock }
The mitigation is to design internal helpers that accept an already-locked value (taking `&mut AppState` directly) rather than acquiring the lock themselves.
Rule 6: Prefer Message Passing When Ownership Can Transfer
When shared state is not strictly necessary, message passing sidesteps the mutex problem entirely. Tokio ships three channel types covering the most common concurrency patterns:
- `tokio::sync::mpsc`: multi-producer, single-consumer; the standard choice for funneling work from many tasks to one owner.
- `tokio::sync::oneshot`: a single-use channel for returning one response from a spawned task.
- `tokio::sync::broadcast`: fan-out delivery, where every receiver gets a copy of each message.
Each channel type transfers ownership of the sent value, eliminating shared references altogether. The actor pattern, where one task owns a piece of state and all others communicate with it via `mpsc`, is a particularly clean application of this principle and removes the need for a mutex entirely.
The Pre-flight Checklist
Before shipping concurrent async Rust, keep this list next to your editor:
- No `.await` inside a lock guard's scope. Finish all async work before acquiring the mutex.
- Use `std::sync::Mutex` or `parking_lot::Mutex` for plain data. Reserve `tokio::sync::Mutex` for I/O resources like database connections.
- Wrap shared state in `Arc<Mutex<T>>`. Never pass raw mutable references across task boundaries.
- Centralize lock acquisition. One type owns the mutex; callers interact through its API only.
- No re-entrant locking. Internal helpers should accept locked values, not acquire locks themselves.
- Default to channels when ownership can transfer. Reach for `mpsc`, `oneshot`, or `broadcast` before reaching for a mutex.
Biriukov's deeper work on these patterns, including his guide "Async Rust with Tokio I/O Streams: Backpressure, Concurrency, and Ergonomics" and his detailed writing on shared mutable state management, is published at biriukov.dev. His engagement with Cloudflare's open-source Ecdysis project, a system for zero-downtime deploys where existing connections continue flowing during a process restart, represents the production end of this discipline: systems where concurrency correctness is not a theoretical concern but an operational requirement.
The compiler catches a remarkable amount. It does not catch a `DashMap Ref` held across an `.await`. It does not catch the refactored function that silently turned a synchronous mutex holder into an async one. The six rules above catch those, and they fit on one screen for a reason.
Know something we missed? Have a correction or additional information?
Submit a Tip

