Be Wary of Shared-State Parallelism

Why shared mutable state between threads remains dangerous even in Rust, how deadlocks and lock contention undermine performance, and practical strategies to reduce risk.

Rust's ownership system eliminates data races at compile time. That guarantee is real, and it is the foundation of Rust's "fearless concurrency" story. But fearlessness about data races is not the same as fearlessness about shared mutable state. Shared-state parallelism still carries two other serious problems that Rust cannot solve on its own: deadlocks and performance collapse under contention. A program that compiles and runs without a single data race can still grind to a halt or produce wrong results for reasons the compiler never warned you about.

This chapter is about what can still go wrong, why it matters, and how to think about shared state so you do not walk into those traps blindly.

Data Races Are No Longer a Problem in Rust

A data race happens when two threads access the same memory location without synchronization, at least one of them writing, and nothing enforces ordering between them. In C++, Java, Go, Python — in essentially every mainstream language — a data race leads to undefined behavior or silently corrupted values. Debugging such bugs is nightmarish because they appear nondeterministically, often only under load, and they rarely reproduce in unit tests.

Rust makes data races impossible to express in safe code. To see why, consider a bank account with a deposit method that modifies a balance. In C++, multiple threads calling deposit on the same account without a mutex would compile cleanly and then crash or corrupt balances in production. In Rust, the same attempt fails at the compiler.

// This will not compile as written—which is exactly the point.
use std::thread;
struct BankAccount {
    balance: i64,
}
impl BankAccount {
    fn deposit(&mut self, amount: i64) {
        self.balance += amount;
    }
}
fn main() {
    let mut account = BankAccount { balance: 0 };
    // Attempt to hand a mutable reference to two threads
    let t1 = thread::spawn(|| {
        account.deposit(100);
    });
    let t2 = thread::spawn(|| {
        account.deposit(200);
    });
    t1.join().unwrap();
    t2.join().unwrap();
}

The compiler rejects this because account would need to be sent into both closures, but a &mut BankAccount is not Sync — it cannot be shared between threads. There is no way to create two simultaneous mutable references to the same data. Without a mutable reference, you cannot write. Without a write, there can be no data race. The type system enforces the safety.

To make this compile correctly, we must wrap the balance in a synchronization primitive like Mutex and share ownership with Arc.

use std::sync::{Arc, Mutex};
use std::thread;
struct BankAccount {
    balance: i64,
}
impl BankAccount {
    fn deposit(&mut self, amount: i64) {
        self.balance += amount;
    }
}
fn main() {
    let account = Arc::new(Mutex::new(BankAccount { balance: 0 }));
    let mut handles = vec![];
    for _ in 0..2 {
        let account = Arc::clone(&account);
        let handle = thread::spawn(move || {
            let mut acc = account.lock().unwrap();
            acc.deposit(100);
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().unwrap();
    }
    let acc = account.lock().unwrap();
    println!("Final balance: {}", acc.balance);
}

Data Race Safety Achieved:

This code compiles and will never suffer a data race. The Mutex guarantees exclusive access, and the Arc safely shares the mutex across threads.

Rust has completely removed one of the two great terrors of shared-state concurrency. The other terror — deadlocks — is still entirely present.

Deadlocks Still Haunt Shared-State Code

A deadlock occurs when two or more threads each hold a resource the other needs, blocking forever. No data is corrupted; the program simply stops making progress. Rust's type system does not and cannot prevent this kind of logic error.

The classic scenario involves two mutexes and inconsistent locking order. Suppose a payment-processing system holds two locks: one for a sender's account and one for a receiver's.

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
type Account = Arc<Mutex<i64>>;
fn transfer(from: &Account, to: &Account, amount: i64) {
    // Bad: lock order depends on argument order.
    let mut from_guard = from.lock().unwrap();
    // Simulate some work between the two locks
    thread::sleep(Duration::from_millis(10));
    let mut to_guard = to.lock().unwrap();
    *from_guard -= amount;
    *to_guard += amount;
}
fn main() {
    let alice: Account = Arc::new(Mutex::new(1000));
    let bob: Account = Arc::new(Mutex::new(1000));
    let a1 = Arc::clone(&alice);
    let b1 = Arc::clone(&bob);
    let a2 = Arc::clone(&alice);
    let b2 = Arc::clone(&bob);
    let t1 = thread::spawn(move || {
        transfer(&a1, &b1, 100); // locks alice, then bob
    });
    let t2 = thread::spawn(move || {
        transfer(&b2, &a2, 50);  // locks bob, then alice
    });
    t1.join().unwrap();
    t2.join().unwrap();
}

Thread 1 acquires alice and then waits for bob. Thread 2 acquires bob and then waits for alice. Neither thread can proceed, and the program deadlocks. The compiler will not warn you about this. The bug is a property of how you structured the lock acquisition — a human design error.

Deadlocks Are Silent:

A deadlocked program does not crash with a nice error message. It hangs. In production, the first sign is usually a timeout on some health check, not a helpful stack trace. If the deadlock is rare, it may survive months of testing before surfacing under load.

Preventing Deadlocks with Lock Ordering

The simplest defence is to enforce a consistent global order: all threads acquire locks in the same sequence. If every function that needs both alice and bob always locks alice first, the deadlock cannot happen.

fn transfer(from: &Account, to: &Account, amount: i64) {
    // Force a consistent order using the pointer address
    // (only for demonstration; real code might use a unique ID)
    let first = std::cmp::min(from.as_ref() as *const _, to.as_ref() as *const _);
    let second = if first == from.as_ref() as *const _ { to } else { from };
    let mut guard1 = first.lock().unwrap();
    let mut guard2 = second.lock().unwrap();
    // now safe to modify both accounts
}

This approach works but is fragile. It requires every developer touching the codebase to know and respect the ordering convention. A single violation anywhere can create a deadlock that surfaces only in a specific, unlucky interleaving.

Lock Ordering Is Not Enforced by the Compiler:

Unlike data races, Rust has no way to verify that lock ordering rules are followed across a codebase. You must enforce this through code review, documentation, and tests that intentionally shuffle thread scheduling.

Other Deadlock Scenarios

  • Lock re-acquisition. The standard library Mutex is not re-entrant. If a thread that already holds a lock tries to lock() the same mutex again, it deadlocks waiting for itself. This is usually a code smell and easily caught in code review, but Rust won't prevent it.
  • Holding a lock while awaiting external work. If a thread holds a lock and then blocks on I/O, a channel, or a barrier that requires another thread — which itself needs that lock — a deadlock emerges. The fix: never hold a lock while waiting on something unknown.
  • Mutex poisoning. If a thread panics while holding a mutex, the mutex becomes "poisoned." Subsequent lock() calls return an Err. This is not a deadlock but can halt a system if callers unwrap() without handling poisoning.

How Lock Contention Can Strangle Performance

Even when no thread deadlocks, shared mutable state can destroy the parallelism you hoped to achieve. A mutex serializes access: only one thread enters the critical section at a time. If every thread needs the same lock frequently, the program effectively becomes single-threaded while waiting on that lock.

Consider a counter shared across many threads. Using a Mutex<i32> works, but each increment forces all threads to line up.

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
    let counter = Arc::new(Mutex::new(0u64));
    let mut handles = vec![];
    for _ in 0..8 {
        let c = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1_000_000 {
                let mut num = c.lock().unwrap();
                *num += 1;
            }
        }));
    }
    for h in handles {
        h.join().unwrap();
    }
    println!("{}", *counter.lock().unwrap());
}

The code is correct and free of data races. But the mutex creates a hot sequential bottleneck. On an 8-core machine, you might observe that CPU utilization stays low because threads spend most of their time waiting on lock().

Amdahl’s Law in Practice:

The fraction of a program that must run serially limits the maximum speedup from parallelization. If 10% of your work happens inside a mutex that only one thread can hold, the theoretical ceiling is 10× speedup, no matter how many cores you add. In reality, the overhead of contention makes it worse.

Mitigation: Atomic Types for Simple Counters

For simple integers, use std::sync::atomic types instead of a Mutex. Atomic operations use hardware-level support to avoid locking altogether while remaining safe.

use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
fn main() {
    let counter = Arc::new(AtomicU64::new(0));
    let mut handles = vec![];
    for _ in 0..8 {
        let c = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1_000_000 {
                c.fetch_add(1, Ordering::Relaxed);
            }
        }));
    }
    for h in handles {
        h.join().unwrap();
    }
    println!("{}", counter.load(Ordering::Relaxed));
}

This version scales far better. No thread blocks waiting for a lock; each increment is a single atomic instruction. The cost is that atomics only work for simple primitive types and a fixed set of operations (add, swap, compare_exchange, etc.). Complex data structures still need locks.

Mitigation: Minimizing Critical Section Size

When a mutex is unavoidable, hold it for as little time as possible. Compute everything you can before acquiring the lock, then do only the bare minimum inside.

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
    let results = Arc::new(Mutex::new(Vec::new()));
    let mut handles = vec![];
    for i in 0..4 {
        let res = Arc::clone(&results);
        handles.push(thread::spawn(move || {
            // Expensive computation outside the lock
            let data = (0..10_000).map(|x| x * i).sum::<u64>();
            // Only the push needs synchronization
            res.lock().unwrap().push(data);
        }));
    }
    for h in handles {
        h.join().unwrap();
    }
    println!("{:?}", results.lock().unwrap());
}

Threads spend most of their time computing data in parallel. The lock is held only for the brief moment of pushing one value. The performance difference compared to holding the lock across the entire computation can be enormous.

Don’t Overdo Fine-Grained Locking:

Breaking operations into many tiny critical sections can introduce correctness bugs if the state must remain consistent across multiple steps. For example, checking a condition and then acting on it often requires an atomic check-and-act sequence — otherwise another thread may change the state between the check and the action. If your logic depends on invariants spanning several fields, a single lock held across the whole transaction is often simpler and safer.

The Hidden Complexity of Mutable Shared State

Rust guarantees that your mutex-protected data won't be accessed without the lock held, and that the lock is correctly released when the guard drops. That prevents data races. It does not guarantee that your program's logic is correct.

When multiple threads modify shared data, reasoning about program state becomes exponentially harder. Invariants that are simple in a single-threaded program — "the sum of these fields must always equal 100", "this list must remain sorted" — can be broken by perfectly safe, lock-protected code if the locking granularity is wrong or the order of operations is not carefully designed.

A common anti-pattern is checking a condition and then acting on it in two separate critical sections. Consider a bank transfer that checks a balance and then deducts it.

fn withdraw(account: &Mutex<i64>, amount: i64) -> bool {
    {
        let balance = account.lock().unwrap();
        if *balance < amount {
            return false;
        }
        // Lock released here
    }
    // Time-of-check to time-of-use gap
    let mut balance = account.lock().unwrap();
    *balance -= amount;
    true
}

Between the two lock acquisitions, another thread can drain the account. The code compiles, uses locks correctly, and is free of data races — but it has a logic bug that can cause the balance to go negative. The fix is to hold the lock across the entire check-and-modify sequence, making the operation atomic from the caller's perspective.

Race Conditions Are Not Data Races:

A race condition occurs when the correctness of the program depends on the timing of events — even when all accesses are properly synchronized. Rust eliminates data races (unsynchronized conflicting access), not race conditions (flawed assumptions about ordering). The distinction is critical.

How to Use Shared State Without Getting Burned

Shared-state parallelism is sometimes the right tool — when the data genuinely must be accessible from many threads and message passing would be too cumbersome. But it demands discipline.

  • Prefer message passing with channels wherever the problem can be modeled as independent actors communicating through messages. The standard library's mpsc and crates like crossbeam or tokio::sync::mpsc give you clean, ownership-based communication.
  • Use atomic types for simple shared counters and flags. They are lock-free and performant, and they remove the risk of deadlocks entirely.
  • When you reach for a Mutex, ask whether a RwLock fits better. If reads far outnumber writes, RwLock allows many readers simultaneously, reducing contention. But weigh the overhead — RwLock can be slower than Mutex when the critical section is tiny.
  • Document lock ordering. If your design requires acquiring multiple locks, write down the required order and make it visible to every contributor.
  • Test under concurrency stress. Unit tests rarely expose deadlocks or race conditions. Use tools like loom for systematic permutation testing of concurrent code, or run your code under high thread counts repeatedly to flush out nondeterministic bugs.
  • Keep critical sections small and pure. Avoid I/O, channel sends, or lock acquisitions inside a critical section unless absolutely necessary and carefully reasoned.

Shared State Is Not Evil — It’s Just Sharp:

Rust gives you the world's best safety net against data races, but shared mutable state still requires careful engineering. The same tools that make C++ concurrency terrifying can be used safely and productively in Rust — as long as you remain aware of the pitfalls the compiler cannot catch.

Summary

Rust's ownership model eliminates data races, which is a monumental achievement. But shared-state parallelism still exposes programs to deadlocks, lock contention, and logic errors that arise from the fundamental difficulty of reasoning about state across concurrent execution. The compiler will not save you from a bad lock order or a critical section that is too coarse.

The guiding principle is to reach for shared mutable state last, not first. Channels, atomics, and careful encapsulation often produce designs that are both simpler and faster. When you must share mutable data, treat every lock as a deliberate design decision, document your assumptions, and test aggressively. Fearless concurrency is real, but it is not a license to ignore concurrency's remaining challenges.