Shared Mutable State

Learn how to safely share and modify data across threads in Rust using Mutex, Arc, RwLock, and Condvar — including deadlock avoidance and mutex poisoning.

When multiple threads need to read and write the same piece of data, message passing alone isn’t enough. Message passing moves ownership of data between threads — it’s a “share by communicating” model. Shared mutable state works the other way around: threads keep access to a common data structure and synchronize who can change it. Rust’s ownership system makes this notoriously hard problem manageable, not by making it easy, but by refusing to compile unsafe patterns.

This section covers the core primitives for shared mutable state: Mutex, Arc, RwLock, and Condvar. You’ll see what they do, why they exist, how they work together, and what can go wrong when you use them — along with the compile-time checks that stop most of those failures before they happen.

What a Mutex Is

A mutex (short for mutual exclusion) guarantees that only one thread at a time can access the data it guards. To work with the guarded value, a thread must acquire the lock. If another thread holds the lock, the requesting thread blocks until the lock is released. When the lock is released, the next waiting thread can acquire it.

Think of a single microphone at a panel discussion. Only the panelist holding the microphone can speak; everyone else waits. When the speaker is done, they hand the microphone to the next person who asked for it. If someone forgets to hand it back, nobody else can speak. A mutex enforces the same discipline at the programming level — without the social awkwardness.

The two rules you must follow with every mutex in any language are:

  1. Always acquire the lock before touching the guarded data.
  2. Always release the lock when you’re done.

Rust enforces both rules through its type system, so forgetting to lock or unlock is a compile-time error, not a runtime disaster.

Mutex in the standard library:

Rust provides std::sync::Mutex<T>. The generic parameter T is the data type being guarded. Mutex<T> wraps that value and requires locking to reach it.

The Mutex API in Single-Threaded Code

Before juggling threads, understand Mutex<T> on its own. Even without concurrency, the API forces you to acquire a lock — showing that the type system won’t let you touch the inner value unprotected.

use std::sync::Mutex;
fn main() {
    let m = Mutex::new(5);
    {
        let mut num = m.lock().unwrap();
        *num = 6;
    } // lock released here when `num` goes out of scope
    println!("m = {:?}", m);
}

Running this prints m = Mutex { data: 6, .. }. The outer block shows a scoped lock. Inside, m.lock() returns a Result<MutexGuard<i32>, PoisonError<...>>. The unwrap() gives us the guard if the mutex is not poisoned (more on poisoning later). The guard implements DerefMut, so we can modify the inner value through it. At the end of the inner block, the guard goes out of scope and its Drop implementation releases the lock automatically.

The critical point is that Mutex<i32> is a distinct type from i32. You cannot accidentally use the integer without locking — the compiler rejects *m or m + 1 because Mutex<i32> does not implement Add or Deref to the inner value. You must call .lock(). That’s the compiler enforcing the “acquire before use” rule.

No forgotten unlocks:

The MutexGuard releases the lock when dropped. This eliminates entire classes of bugs where a lock is held too long or never released. Rust’s scoping rules handle it for you.

Sharing a Mutex Across Threads

The single-threaded example is safe but not useful. To share state, multiple threads need access to the same Mutex. A first attempt runs into ownership rules.

use std::sync::Mutex;
use std::thread;
fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];
    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().unwrap();
    }
    println!("Result: {}", *counter.lock().unwrap());
}

This fails to compile. The error points out that counter is moved into the closure in the first iteration, and can’t be used again. Mutex<i32> doesn’t implement Copy, so each thread would need its own copy — but we need a single shared instance.

The problem is that multiple threads need to own the same data simultaneously. In single-threaded code we’d reach for Rc<T> to provide shared ownership through reference counting. Let’s try wrapping the mutex in Rc:

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];
    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }
    // ... join and print
}

Now the compiler gives a different error: Rc<Mutex<i32>> cannot be sent between threads safely. Rc uses non-atomic reference counting, which is fast in single-threaded contexts but unsafe across threads — two threads incrementing the reference count simultaneously would produce a data race on the count itself.

The fix is Arc<T>, the atomic reference-counted pointer. Arc uses atomic operations to adjust the reference count, making it safe to share across threads. Pairing Arc with Mutex gives exactly what we need: multiple threads own the same mutex, and the mutex ensures that only one thread at a time touches the inner data.

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

This compiles and prints Result: 10. Every thread takes a clone of the Arc, which increments the atomic reference count. Inside the closure, counter.lock() acquires the mutex lock, increments the value, and releases the lock when num drops at the end of the closure. The main thread waits for all spawned threads, then locks one final time to read the result.

Avoid holding locks across .await points in async code:

The standard Mutex is not designed for async contexts. Holding a standard mutex guard across an .await will cause deadlocks or panics. For async Rust, use tokio::sync::Mutex instead.

Why Mutexes Are Not Always a Good Idea

The Arc<Mutex<T>> pattern works, but it has trade-offs. A mutex serializes access: if many threads contend for the same lock, they end up waiting in line, effectively turning concurrent code into sequential code plus overhead. In high-contention scenarios, a mutex can become the bottleneck.

There are also cognitive overheads: the more mutexes you sprinkle across a codebase, the harder it becomes to reason about lock ordering. And deadlocks — covered next — become a real possibility when you hold multiple locks at once.

Whenever you reach for a mutex, ask yourself whether the shared state truly needs to be written to by multiple threads, or whether the problem can be restructured to avoid sharing mutable memory in the first place. Channels, fork-join patterns, and data parallelism (using rayon) often sidestep the need for explicit locks.

Deadlock

A deadlock happens when two or more threads each hold a lock that the other needs, and none of them can proceed. The classic scenario involves two mutexes, A and B:

  • Thread 1 locks A, then tries to lock B.
  • Thread 2 locks B, then tries to lock A.

Each thread holds the lock the other wants, so both wait forever. Rust cannot detect this at compile time; it’s a runtime logical error.

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
fn main() {
    let a = Arc::new(Mutex::new(0));
    let b = Arc::new(Mutex::new(0));
    let a1 = Arc::clone(&a);
    let b1 = Arc::clone(&b);
    let handle1 = thread::spawn(move || {
        let _guard_a = a1.lock().unwrap();
        thread::sleep(Duration::from_millis(50));
        let _guard_b = b1.lock().unwrap();
    });
    let a2 = Arc::clone(&a);
    let b2 = Arc::clone(&b);
    let handle2 = thread::spawn(move || {
        let _guard_b = b2.lock().unwrap();
        thread::sleep(Duration::from_millis(50));
        let _guard_a = a2.lock().unwrap();
    });
    handle1.join().unwrap();
    handle2.join().unwrap();
}

This program will deadlock and hang indefinitely. Both threads sleep briefly after acquiring their first lock to make the timing window obvious.

To avoid deadlocks, establish a consistent lock ordering: always lock A before B in every thread. If you can’t enforce ordering globally, consider using try_lock() to attempt a non-blocking acquisition, or restructure the code so that a thread never holds more than one lock at a time.

Deadlocks are silent killers:

A deadlocked program hangs with no error message, no panic, no log. Threads just stop making progress. This is one of the hardest concurrency bugs to diagnose, so designing to prevent it is far better than debugging it later.

Poisoned Mutexes

A mutex becomes poisoned when a thread panics while holding its lock. The rationale is that the guarded data might be left in an inconsistent state — the panicking thread might have been halfway through updating an invariant. To protect callers from reading corrupted data, the mutex marks itself as poisoned, and subsequent calls to lock() return a PoisonError wrapping the guard.

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
    let data = Arc::new(Mutex::new(42));
    let data_clone = Arc::clone(&data);
    let handle = thread::spawn(move || {
        let _guard = data_clone.lock().unwrap();
        panic!("thread panicked while holding the lock");
    });
    let _ = handle.join();
    // This lock attempt will get a PoisonError
    match data.lock() {
        Ok(guard) => println!("Got data: {}", *guard),
        Err(poisoned) => {
            // We can still recover the inner guard and data
            let mut guard = poisoned.into_inner();
            println!("Recovered from poison: {}", *guard);
            *guard = 0; // repair the state
        }
    }
}

Output:

Recovered from poison: 42

The lock() call after the panic returns Err(PoisonError). The into_inner() method on the error gives us back the MutexGuard, allowing us to inspect and possibly repair the data. You can also use lock().unwrap() if you’re confident that a panic doesn’t corrupt your data, but that’s a deliberate choice, not a default.

In most application code, you won’t handle poisoning explicitly because a panic that leaks into mutex-holding code is usually a sign of a bug you want to catch early. But for long-running servers or critical systems, the poison mechanism lets you isolate failures and recover safely.

Multi-producer Channels Using Mutexes

Rust’s standard mpsc::channel provides a single producer, single consumer (well, single producer that can be cloned, but each clone is a separate producer). If you need to share a sender among multiple threads and the standard clone isn’t sufficient — or you’re using a channel type that doesn’t support cloning — you can wrap the sender in Arc<Mutex<Sender<T>>> to allow multiple threads to send through the same sender.

use std::sync::{Arc, Mutex};
use std::sync::mpsc;
use std::thread;
fn main() {
    let (tx, rx) = mpsc::channel();
    let tx = Arc::new(Mutex::new(tx));
    let mut handles = vec![];
    for id in 0..5 {
        let tx = Arc::clone(&tx);
        handles.push(thread::spawn(move || {
            let sender = tx.lock().unwrap();
            sender.send(format!("thread {} says hello", id)).unwrap();
        }));
    }
    // Drop the original Arc reference to close the channel after all sends
    drop(tx);
    for handle in handles {
        handle.join().unwrap();
    }
    for received in rx {
        println!("Got: {}", received);
    }
}

Each thread locks the mutex briefly to send one message. The lock ensures that send calls don’t interleave, which the Sender type itself doesn’t protect against when shared via raw pointers. This pattern is particularly useful when the sender is not Clone — for example, with a custom channel implementation — or when you need to store the sender in a structure shared across threads.

Read/Write Locks (RwLock<T>)

A mutex provides exclusive access: only one thread can be reading or writing at a time. In workloads where reads vastly outnumber writes, this is unnecessarily restrictive. RwLock<T> (read-write lock) allows either multiple simultaneous readers or exactly one writer. When no writer is active, any number of threads can acquire read locks. When a writer wants access, it blocks new readers and waits for existing readers to finish, then performs its write exclusively.

use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
    let data = Arc::new(RwLock::new(vec![1, 2, 3]));
    let mut handles = vec![];
    // Reader threads
    for _ in 0..5 {
        let data = Arc::clone(&data);
        handles.push(thread::spawn(move || {
            let guard = data.read().unwrap();
            println!("Reading: {:?}", *guard);
        }));
    }
    // Writer thread
    let data = Arc::clone(&data);
    handles.push(thread::spawn(move || {
        let mut guard = data.write().unwrap();
        guard.push(4);
        println!("Writing: {:?}", *guard);
    }));
    for handle in handles {
        handle.join().unwrap();
    }
    println!("Final: {:?}", *data.read().unwrap());
}

read() returns a RwLockReadGuard (implements Deref to &T), while write() returns a RwLockWriteGuard (implements DerefMut to &mut T). The lock is released when the guard drops.

RwLock shines when reads dominate and are frequent. However, it’s not free: the internal bookkeeping is heavier than a plain mutex, and under heavy write load, reader starvation can occur if writers never get a chance because readers keep the lock busy. For CPU-bound concurrent reads, a mutex might even be faster — benchmark with your real workload.

Prefer Mutex unless reads dominate:

The overhead of RwLock can outweigh its benefit if writers are common or if critical sections are extremely short. Measure before assuming RwLock is "better" than Mutex.

Condition Variables (Condvar)

A condition variable allows threads to wait for a specific condition to become true, rather than polling a mutex in a loop. It works together with a Mutex: one thread acquires the lock, checks a predicate, and if the condition isn’t met, calls wait to atomically release the lock and block. Another thread can later change the condition and call notify_one or notify_all to wake waiting threads, which re-acquire the lock and re-check the predicate.

use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::Duration;
fn main() {
    let pair = Arc::new((Mutex::new(false), Condvar::new()));
    let pair_clone = Arc::clone(&pair);
    let waiter = thread::spawn(move || {
        let (lock, cvar) = &*pair_clone;
        let mut started = lock.lock().unwrap();
        while !*started {
            started = cvar.wait(started).unwrap();
        }
        println!("Waiter sees started = true");
    });
    // Simulate some work before signaling
    thread::sleep(Duration::from_millis(100));
    let (lock, cvar) = &*pair;
    let mut started = lock.lock().unwrap();
    *started = true;
    println!("Signaling waiter");
    cvar.notify_one();
    waiter.join().unwrap();
}

The wait method takes a MutexGuard and returns a new MutexGuard when the thread is woken, ensuring the predicate is re-checked under the lock. A while loop (not just if) is essential because spurious wakeups can occur; the condition must be re-evaluated after waking.

Condvar is the lowest-level building block for signaling between threads. Higher-level abstractions like channels or barriers are often more ergonomic, but Condvar is indispensable when you need to build custom synchronization logic — for example, implementing a thread pool where workers wait for tasks to appear in a shared queue.

Always wrap wait in a while loop:

Use while !condition { guard = cvar.wait(guard).unwrap(); } — never if. Spurious wakeups and race conditions between notify and wake-up mean you must re-verify the condition after wait returns.

Summary

Shared mutable state in Rust revolves around a small set of types: Mutex for exclusive access, Arc for thread-safe shared ownership, RwLock for read-heavy workloads, and Condvar for signaling. The compiler ensures you never touch guarded data without locking, and the ownership system prevents accidental sharing across thread boundaries unless the types are explicitly thread-safe (Send and Sync).

The hardest problems with shared state — deadlocks, contention, poisoned data — are not eliminated by Rust, but the language gives you tools to catch misuse at compile time and a standard library that enforces safe patterns. When you do need shared mutable state, Arc<Mutex<T>> is the bread-and-butter combination; use it deliberately and always question whether a lock-free or message-passing design could avoid the complexity entirely.

If you want to go deeper, explore atomic types (AtomicBool, AtomicUsize, etc.) and compare them to mutex-based synchronization. Atomics and global variables often provide a lighter-weight alternative for shared state that needs only simple operations.