Deadlocks and Race Conditions
While Safe Rust guarantees at compile time that data races (unsynchronized memory corruption) cannot occur, concurrent programs can still experience deadlocks (threads waiting on one another in a permanent cycle) and logical race conditions (incorrect results caused by non-deterministic lock or message ordering).
How Deadlocks Occur (Coffman Conditions and AB-BA Lock Ordering)
A deadlock occurs when a cycle of threads each holds a lock that the next thread in the cycle is waiting to acquire:
Classic AB-BA Deadlock:
Thread 1 Thread 2
-------- --------
1. Locks `Account A` 1. Locks `Account B`
2. Tries to lock `Account B` (Blocks!) 2. Tries to lock `Account A` (Blocks!)
\ /
+---> Both threads wait forever! <------+
Another common single-lock deadlock is same-thread re-locking: because std::sync::Mutex<T> is not re-entrant, calling mutex.lock() a second time on the same thread while an earlier MutexGuard is still in scope deadlocks the thread immediately.
Preventing AB-BA Deadlocks with Deterministic Lock Ordering
Whenever an operation must acquire two locks (account_a and account_b), always lock them in a consistent global order—such as sorting by unique account ID or memory address—so all threads acquire the lower-ID lock first and the higher-ID lock second, making a wait cycle impossible:
use std::cmp::Ordering;
use std::sync::Mutex;
use std::thread;
struct Account {
id: u64,
balance: Mutex<i64>,
}
fn transfer(from: &Account, to: &Account, amount: i64) {
// 1. Guard against self-transfer (which would try to lock the same Mutex twice!):
if from.id == to.id {
return;
}
// 2. Always lock the account with the SMALLER `id` first to prevent AB-BA deadlock:
let (mut from_guard, mut to_guard) = match from.id.cmp(&to.id) {
Ordering::Less => {
let g1 = from.balance.lock().unwrap();
let g2 = to.balance.lock().unwrap();
(g1, g2)
}
Ordering::Greater => {
let g2 = to.balance.lock().unwrap();
let g1 = from.balance.lock().unwrap();
(g1, g2)
}
Ordering::Equal => unreachable!(),
};
*from_guard -= amount;
*to_guard += amount;
}
fn main() {
let acct_1 = Account {
id: 1,
balance: Mutex::new(1000),
};
let acct_2 = Account {
id: 2,
balance: Mutex::new(1000),
};
// Concurrent transfers in opposite directions (`1 -> 2` and `2 -> 1`)
// never deadlock because both threads lock `acct_1` before `acct_2`:
thread::scope(|s| {
s.spawn(|| {
for _ in 0..100 {
transfer(&acct_1, &acct_2, 5);
}
});
s.spawn(|| {
for _ in 0..100 {
transfer(&acct_2, &acct_1, 3);
}
});
});
println!(
"final balances -> acct_1: {}, acct_2: {}",
*acct_1.balance.lock().unwrap(),
*acct_2.balance.lock().unwrap()
);
}
final balances -> acct_1: 800, acct_2: 1200
Preventing Check-Then-Act Logical Race Conditions (TOCTOU)
A Time-of-Check to Time-of-Use (TOCTOU) logical race condition happens when a thread locks a mutex to check a condition (if *balance.lock().unwrap() >= 100), drops the guard, and then locks the mutex a second time to deduct the amount (*balance.lock().unwrap() -= 100). In the window between the two locks, another thread can drain the balance below zero!
Buggy TOCTOU Pattern (Two Separate Lock Scopes):
if *acct.lock().unwrap() >= 100 { // <-- Lock acquired & released!
// Another thread modifies `acct` here!
*acct.lock().unwrap() -= 100; // <-- Second lock sees stale state!
}
Race-Free Atomic Transaction Pattern (Single Lock Guard):
let mut guard = acct.lock().unwrap(); // <-- Single lock held across check AND update!
if *guard >= 100 {
*guard -= 100;
}
Summary of Concurrency Hazard Prevention
| Concurrency Hazard | Prevented by Compiler? | Prevention Strategy |
|---|---|---|
| Data race (unsynchronized memory access) | Yes (Send, Sync, &mut rules) |
Automatic in Safe Rust |
| Same-thread re-lock deadlock | No | Drop MutexGuard early via { ... } or drop(guard) before calling helpers |
| Multi-lock AB-BA deadlock | No | Enforce a strict global lock acquisition order (or use try_lock with backoff) |
| TOCTOU logical race condition | No | Keep a single MutexGuard alive across both the check and the state update (or use compare_exchange) |