How to Use Arc<Mutex<T>> for Thread-Safe Mutable State

Use Arc::new(Mutex::new(value)) to safely share and mutate data across multiple threads in Rust.

When threads need to share and change data

You are building a multi-threaded worker pool. Ten threads are processing jobs. They all need to update a shared progress counter. You try to pass a plain &mut reference to each thread, and the compiler rejects the code. You try & references, and the compiler rejects that too because you are mutating. You need a way to share ownership across threads and allow mutation without data races. That combination is Arc<Mutex<T>>.

Arc handles ownership. It lets multiple threads point to the same heap allocation. When the last thread drops its Arc, the memory is freed. Mutex handles access. It ensures only one thread can touch the data at a time.

Think of a physical safe with a single key. Arc is like photocopying the key. Everyone gets a copy. Mutex is the lock on the safe. Even though everyone has a key, only one person can turn the lock and open the door. If someone else tries, they wait until the door closes and locks again.

The minimal pattern

The structure is always Arc::new(Mutex::new(value)). You wrap the data in the mutex first, then wrap the mutex in the arc. This order matters. The mutex protects the data. The arc shares the mutex.

use std::sync::{Arc, Mutex};
use std::thread;

/// Demonstrates sharing a mutable counter across threads.
fn main() {
    // Wrap the value in Mutex first to protect mutation.
    // Then wrap in Arc to allow shared ownership across threads.
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        // Clone the Arc, not the Mutex or the value.
        // This increments the reference count, giving the thread ownership.
        // Convention: use Arc::clone explicitly to signal a shallow clone.
        let counter = Arc::clone(&counter);
        
        let handle = thread::spawn(move || {
            // Lock the mutex to get a guard.
            // This blocks until the lock is available.
            // unwrap() panics if the mutex is poisoned by a panic in another thread.
            let mut num = counter.lock().unwrap();
            
            // Mutate the value while holding the lock.
            // The guard enforces exclusive access.
            *num += 1;
            
            // Lock guard drops here, releasing the mutex automatically.
            // This is RAII: resource acquisition is initialization.
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    // Lock one last time to read the result.
    println!("Result: {}", *counter.lock().unwrap());
}

The pattern is rigid: Arc::new(Mutex::new(value)). Wrap the data, then wrap the wrapper.

What happens under the hood

At compile time, the compiler checks traits. thread::spawn requires the closure to be Send. Send means the value can be transferred to another thread. Arc<T> is Send if T is Send. Mutex<T> is Send if T is Send. Since i32 is Send, the whole chain is Send. The compiler allows the move.

There is also the Sync trait. Sync means &T is Send. Arc<T> is Sync if T is Send. This allows you to share an Arc across threads via references. The combination of Send and Sync guarantees that the data can move and be shared safely.

At runtime, Arc::clone performs an atomic increment. This is a hardware instruction that guarantees the counter updates correctly even if two threads clone simultaneously. Mutex::lock checks the internal state. If the lock is free, the thread proceeds. If not, the thread parks itself, yielding the CPU. When the guard drops, the mutex wakes a waiting thread.

The compiler enforces Send at compile time. The mutex enforces exclusivity at runtime. Both are required for safety.

Realistic usage: a job queue

In real code, you rarely share a single integer. You share complex state. A job queue is a common pattern. Threads pop jobs from a shared vector.

use std::sync::{Arc, Mutex};
use std::thread;

/// Shared job queue for worker threads.
struct JobQueue {
    jobs: Vec<String>,
}

impl JobQueue {
    /// Removes and returns the last job.
    fn pop(&mut self) -> Option<String> {
        self.jobs.pop()
    }
}

fn main() {
    let queue = Arc::new(Mutex::new(JobQueue {
        jobs: vec!["task1".to_string(), "task2".to_string(), "task3".to_string()],
    }));

    let mut handles = vec![];

    for id in 0..5 {
        let queue = Arc::clone(&queue);
        let handle = thread::spawn(move || {
            // Lock the queue to check for work.
            // The lock scope is limited to this block.
            {
                let mut q = queue.lock().unwrap();
                
                if let Some(job) = q.pop() {
                    println!("Thread {id} processing: {job}");
                } else {
                    println!("Thread {id} found no work.");
                }
            }
            // Lock released when q drops at the end of the block.
            // This minimizes the time the lock is held.
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}

Keep the critical section small. Lock, do the work, drop. Holding a lock across a network call is a performance disaster.

Pitfalls and errors

Deadlocks happen when threads wait for each other. If you hold a lock and try to acquire the same lock again, you deadlock. The thread waits for itself. The program hangs. Rust does not prevent all deadlocks at compile time. You must manage lock ordering. If you have two mutexes, always lock them in the same order across all threads.

Lock poisoning is another risk. If a thread panics while holding a lock, the mutex becomes poisoned. Subsequent lock() calls return an error. unwrap() turns that error into a panic. The whole thread pool can cascade into panics. Use lock().unwrap_or_else(|e| e.into_inner()) if you want to recover the data, or handle the error explicitly.

If you put a non-Send type inside, the compiler rejects it. Rc<T> and RefCell<T> are not Send. If you try Arc::new(Mutex::new(Rc::new(1))), you get E0277 (trait bound not satisfied). The error tells you the type does not implement Send. Replace Rc with Arc and RefCell with Mutex to fix this.

Lock granularity affects performance. Locking too much blocks threads unnecessarily. Locking too little risks data races if you drop the guard early. Profile your code. If the mutex is a bottleneck, consider splitting the state or using RwLock.

Treat the lock guard like a loan. Return it immediately. If you panic while holding it, you break the contract for everyone else.

Community conventions

The standard library Mutex is correct but can be slow under high contention. Many Rust projects switch to parking_lot::Mutex for better performance. The API is identical. You can drop it in without changing code. Check your dependencies. If performance matters, parking_lot is the standard choice.

Convention dictates using Arc::clone(&value) instead of value.clone(). Both compile and work. The explicit form signals a shallow clone of the pointer. It warns readers that you are not copying the data. This distinction matters when T implements Clone for deep copies.

Treat Arc::clone as a signal. It tells the reader you are sharing ownership, not duplicating data.

When to use Arc<Mutex>

Use Arc<Mutex<T>> when multiple threads need shared ownership and you need to mutate the data.

Use Arc<T> when threads only read the data and T is immutable.

Use RwLock<T> when reads are frequent and writes are rare, and you want concurrent reads.

Use channels (mpsc) when threads communicate by sending messages rather than sharing state.

Use AtomicUsize or AtomicBool when you need lock-free updates to simple values like counters or flags.

Start with channels. Shared state is hard. If you must share state, reach for Arc<Mutex<T>>.

Where to go next