Safe Rust prevents data races when the code and its unsafe dependencies are sound. It does not guarantee that threads make progress. A program can still deadlock, retry forever, starve a worker or lose an update through an incorrect sequence of otherwise safe operations.
Design can remove many causes of deadlock. Tests, thread dumps, tracing and debugger inspection can help find others. A deadlock may affect a subset of threads rather than freezing every part of the process.
Give nested locks a consistent order
Suppose one path holds A while waiting for B, and another holds B while waiting for A. The wrong interleaving can leave both waiting indefinitely. It is a possible deadlock, not a guarantee that every execution will hang.
Assign each lock a stable rank and acquire distinct locks in increasing rank order on every path, including helper calls. This prevents circular waits formed by those locks. Array indices or a documented hierarchy can provide the ranks. Make the ordering visible in the API rather than relying only on comments at individual call sites.
Check aliases too: two references may identify the same lock. Ordering does not help if a thread tries to lock that mutex twice. The standard Mutex does not support recursive locking; locking it again on the same thread has unspecified behavior that may panic or deadlock, and will not return normally.
Lock ordering does not cover every kind of wait. A thread can still hold a lock while joining a worker that needs it, or participate in a cycle involving channels or callbacks. Review those dependencies as well.
Move callbacks and formatting outside the lock
A generic Display implementation, callback or destructor can acquire locks, invoke more code or panic. Running it while holding your own lock adds hidden dependencies to the critical section.
This complete example formats first, then acquires the log lock to append:
use std::fmt::Display;
use std::sync::Mutex;
fn record(log: &Mutex<Vec<String>>, item: &impl Display) {
let line = format!("{item}");
log.lock().unwrap().push(line);
}
fn main() {
let log = Mutex::new(Vec::new());
record(&log, &42);
assert_eq!(*log.lock().unwrap(), vec!["42"]);
}
The push can still grow the vector, so this is not a hard execution-time bound. It removes the user-controlled formatting call from the locked region. When replacing values, consider whether their destructors can also run after releasing the lock, provided the protected invariant permits that ordering.
If formatting depends on protected state, take an appropriate snapshot under the lock and format afterwards. Decide whether a stale snapshot is acceptable; moving work outside a lock can change the operation's semantics.
Protect the entire update
Releasing a lock between reading a counter and writing the incremented value allows two threads to read the same old value. Both can then write the same new value, losing an increment. This is a logic error, even though the mutex prevents unsynchronized memory access.
Keep the read-modify-write under one guard:
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0_u32);
thread::scope(|scope| {
for _ in 0..2 {
let counter = &counter;
scope.spawn(move || {
for _ in 0..1000 {
let mut value = counter.lock().unwrap();
*value += 1;
}
});
}
});
assert_eq!(*counter.lock().unwrap(), 2000);
}
Keep critical sections short enough to avoid unnecessary waiting, but large enough to preserve the invariant. Avoid blocking I/O or joining another thread while holding a guard unless the complete waiting relationship is understood.
Make guard lifetimes visible
A named guard releases its lock when dropped. An inner block or explicit drop makes the release point clear. This example uses try_lock to observe the lock state without blocking on a second acquisition:
use std::sync::{Mutex, TryLockError};
fn main() {
let value = Mutex::new(1);
let mut guard = value.lock().unwrap();
*guard += 1;
assert!(matches!(value.try_lock(), Err(TryLockError::WouldBlock)));
drop(guard);
let guard = value.try_lock().unwrap();
assert_eq!(*guard, 2);
}
Temporary guards can live longer than expected. In edition 2024, a temporary guard in an if-let scrutinee is dropped before the else branch, but remains through a matching then branch. A match scrutinee keeps its temporary guard through the match. Earlier editions also retain if-let temporaries into else.
Naming a guard is useful only if its lifetime matches your intention. Drop
it before reacquiring the same lock; merely giving it a name does not fix a
self-lock. Likewise, let _guard = ... holds a guard, while let _ = ...
discards it immediately and can trigger the let_underscore_lock lint.
Retry protocols need more than try_lock
try_lock returns without waiting. WouldBlock means the lock is unavailable; Poisoned is a different result and requires an explicit recovery or failure policy. Do not silently treat both as ordinary contention.
A protocol can acquire one lock, try another, and release everything it holds if the second is unavailable. This avoids waiting on the second lock while retaining the first. It still needs a progress argument: repeated retries can produce livelock or starvation. Backoff may reduce contention but is not a proof of eventual success. Prefer a clear order when one is practical.
Reduce sharing where it helps
Partitioning data among workers, collecting local results and merging them, or giving state one owner can remove shared-lock dependencies. Message-passing designs can still deadlock through bounded channels or circular requests, so inspect their waiting relationships too.
Sharding divides data among multiple locks. It can reduce contention when traffic is spread across shards; a hot key may still serialize most work. Operations crossing shards need a consistent order and must avoid locking the same shard twice. Atomic operations spanning multiple keys may also need coordination that a per-key design did not require.
Use tools without assuming complete coverage
The standard Mutex API does not enforce a global lock hierarchy or provide a general deadlock detector. Compiler and Clippy checks can catch some suspicious patterns, such as discarding a lock guard immediately or holding one across await. They cannot prove arbitrary concurrent code will keep making progress.
Document lock order and invariant boundaries, test relevant interleavings, and retain enough diagnostics to inspect a stalled program. These practices complement safe Rust's memory guarantees rather than following from them.