Skip to content
rusthacks.dev
Concurrency

Arc Mutex in Rust and the Pattern Behind Shared State

Arc gives several threads shared ownership and Mutex gives them one-at-a-time access. See the full pattern, the deadlock to avoid and what poisoning means.

9 min read
Summarize in AI
Flat illustration for the post “Arc Mutex in Rust and the Pattern Behind Shared State”, composed around threads.

Eight threads incrementing one counter 1,000 times each should print 8,000, and the version of this program that compiles in Rust always does. That is the pitch of Arc<Mutex<T>>: the type system will not let you write the data race, so the interesting bugs move somewhere else, to forgotten locks held too long, to deadlocks and to a lock that a panicking thread left poisoned. This page builds the pattern from the compiler errors up. Everything compiles on Rust 1.99 with edition 2024 and the standard library only.

Why two types, not one

The pattern is two separate guarantees stacked together. Mutex<T> gives one thread at a time access to a value of type T. Arc<T> (atomic reference counting) gives several threads shared ownership of one allocation, which is freed when the last owner drops. A mutex alone cannot be shared, because each thread's closure would need to own it, and there is only one. An Arc alone shares the value but only hands out shared references, which cannot mutate. Put the mutex inside the arc and you get both.

src/main.rsrust
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0u64));
    let mut handles = Vec::new();

    for _ in 0..8 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1_000 {
                let mut guard = counter.lock().unwrap();
                *guard += 1;
            }
        }));
    }
    for h in handles {
        h.join().unwrap();
    }
    println!("total = {}", *counter.lock().unwrap());
    println!("owners left = {}", Arc::strong_count(&counter));
}
Text
total = 8000
owners left = 1

Arc::clone(&counter) bumps a reference count and does not copy the number. Each thread receives its own Arc by move. lock() blocks until the mutex is free and returns a MutexGuard, which dereferences to the inner value and unlocks when dropped, at the end of the loop body. The final count of one owner shows that every clone was released as its thread finished.

Compile errors worth recognizing

Leave out the Arc and put the mutex straight into the closures.

src/main.rsrust
// does not compile
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0u64);
    let mut handles = Vec::new();
    for _ in 0..2 {
        handles.push(thread::spawn(move || {
            *counter.lock().unwrap() += 1;
        }));
    }
    for h in handles {
        h.join().unwrap();
    }
}
Text
error[E0382]: use of moved value: `counter`
  --> src/main.rs:9:36
   |
 6 |     let counter = Mutex::new(0u64);
   |         ------- move occurs because `counter` has type `std::sync::Mutex`, which does not implement the `Copy` trait
 7 |     let mut handles = Vec::new();
 8 |     for _ in 0..2 {
   |     ------------- inside of this loop
 9 |         handles.push(thread::spawn(move || {
   |                                    ^^^^^^^ value moved into closure here, in previous iteration of loop
10 |             *counter.lock().unwrap() += 1;
   |              ------- use occurs due to use in closure

The first iteration's closure took ownership of the mutex, so the second has nothing to take. The compiler is saying that the mutex needs several owners, which is what Arc is for. Now try the single-threaded reference counter instead.

src/main.rsrust
// does not compile
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0u64));
    let c = Rc::clone(&counter);
    thread::spawn(move || {
        *c.lock().unwrap() += 1;
    })
    .join()
    .unwrap();
}
Text
error[E0277]: `Rc<std::sync::Mutex<u64>>` cannot be sent between threads safely
   --> src/main.rs:9:19
    |
  9 |       thread::spawn(move || {
    |       ------------- ^------
    |       |             |
    |       |             within this `{closure@src/main.rs:9:19: 9:26}`
    |       required by a bound introduced by this call
    |
    = help: within `{closure@src/main.rs:9:19: 9:26}`, the trait `Send` is not implemented for `Rc<std::sync::Mutex<u64>>`
note: required by a bound in `spawn`
    |     F: Send + 'static,
    |        ^^^^ required by this bound in `spawn`

Rc updates its count without atomic instructions, so two threads dropping clones at once could corrupt it. The compiler stops that before it runs, and swapping Rc for Arc is the whole fix (why Send and Sync exist).

The lock is released when the guard drops

There is no unlock(). The guard is the lock, so the question "how long is it held" is the question "where does the guard go out of scope". This matters more than anything else on this page, because a lock held across slow work serializes your threads. We measured the same four 50 ms jobs with the sleep inside the lock and outside it.

src/main.rsrust
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};

fn run(hold_during_work: bool) -> Duration {
    let log = Arc::new(Mutex::new(Vec::new()));
    let start = Instant::now();
    let handles: Vec<_> = (0..4)
        .map(|id| {
            let log = Arc::clone(&log);
            thread::spawn(move || {
                if hold_during_work {
                    let mut guard = log.lock().unwrap();
                    thread::sleep(Duration::from_millis(50)); // "work" under the lock
                    guard.push(id);
                } else {
                    thread::sleep(Duration::from_millis(50)); // work first
                    log.lock().unwrap().push(id);
                }
            })
        })
        .collect();
    for h in handles {
        h.join().unwrap();
    }
    start.elapsed()
}

fn main() {
    println!("lock held during work: {:?}", run(true));
    println!("lock held to push only: {:?}", run(false));
}
Text
lock held during work: 215.5695ms
lock held to push only: 55.758125ms

Four threads took 216 ms against 56 ms, about four times as long, when the work sat inside the critical section, because they queued for the lock one by one. Compute first, lock briefly to store the result. In async code the same mistake is worse, since a guard held across an .await can freeze the runtime (holding a guard across an await).

Poisoning

If a thread panics while it holds a guard, the mutex is marked poisoned, and every later lock() returns Err. The reasoning is that the panic may have left the protected data half-updated, and the standard library refuses to pretend otherwise.

src/main.rsrust
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let data = Arc::new(Mutex::new(vec![1, 2, 3]));

    let d = Arc::clone(&data);
    let _ = thread::spawn(move || {
        let mut guard = d.lock().unwrap();
        guard.push(4);
        panic!("crashed while holding the lock");
    })
    .join();

    println!("poisoned: {}", data.is_poisoned());
    match data.lock() {
        Ok(_) => println!("locked normally"),
        Err(poisoned) => {
            let guard = poisoned.into_inner();
            println!("recovered the data anyway: {:?}", *guard);
        }
    }
}
Text
poisoned: true
recovered the data anyway: [1, 2, 3, 4]

Calling .unwrap() on lock(), as every example here does, turns one thread's panic into a panic in every other thread that touches the mutex. That is a defensible default for state you cannot repair. When the data stays valid after a partial update, as with a cache, into_inner() on the error lets you continue. parking_lot::Mutex takes the other view and has no poisoning at all.

Deadlock: two locks, two orders

A mutex cannot deadlock by itself in one thread's use, but two mutexes can. Thread A takes first then waits for second; thread B takes second then waits for first. Neither gives way. The program below hangs, and no compiler or runtime check will warn you.

src/main.rsrust
// hangs: opposite lock order in the two threads
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

fn main() {
    let first = Arc::new(Mutex::new(0));
    let second = Arc::new(Mutex::new(0));

    let (f, s) = (Arc::clone(&first), Arc::clone(&second));
    let a = thread::spawn(move || {
        let _f = f.lock().unwrap();
        thread::sleep(Duration::from_millis(50));
        let _s = s.lock().unwrap();
    });
    let (f, s) = (Arc::clone(&first), Arc::clone(&second));
    let b = thread::spawn(move || {
        let _s = s.lock().unwrap();
        thread::sleep(Duration::from_millis(50));
        let _f = f.lock().unwrap();
    });
    a.join().unwrap();
    b.join().unwrap();
}

The cure is a rule, not a type: every thread takes the locks in the same order, for example always first before second. If you cannot guarantee that, put both values under a single mutex in one struct, so there is only one lock to take. And never call unknown code, such as a callback, while a guard is alive.

The price of locking on every increment

A mutex is cheap when uncontended and expensive when eight threads fight over it. We counted to 16,000,000 two ways in a release build: every thread locking the shared counter on each increment, and every thread counting in a local variable and returning it to be summed at the end.

src/main.rsrust
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Instant;

const THREADS: usize = 8;
const PER_THREAD: u64 = 2_000_000;

fn shared_counter() -> u64 {
    let counter = Arc::new(Mutex::new(0u64));
    let handles: Vec<_> = (0..THREADS)
        .map(|_| {
            let counter = Arc::clone(&counter);
            thread::spawn(move || {
                for _ in 0..PER_THREAD {
                    *counter.lock().unwrap() += 1;
                }
            })
        })
        .collect();
    for h in handles {
        h.join().unwrap();
    }
    let total = *counter.lock().unwrap();
    total
}

fn merge_at_end() -> u64 {
    let handles: Vec<_> = (0..THREADS)
        .map(|_| {
            thread::spawn(|| {
                let mut local = 0u64;
                for _ in 0..PER_THREAD {
                    local = std::hint::black_box(local) + 1;
                }
                local
            })
        })
        .collect();
    handles.into_iter().map(|h| h.join().unwrap()).sum()
}

fn main() {
    let start = Instant::now();
    let a = shared_counter();
    println!("lock on every increment: {a} in {:>8.1?}", start.elapsed());

    let start = Instant::now();
    let b = merge_at_end();
    println!("local count, merge once: {b} in {:>8.1?}", start.elapsed());
}
Text
lock on every increment: 16000000 in  330.3ms
local count, merge once: 16000000 in    4.2ms

The locked version took 330 ms, about 20 ns per increment, and the local version took 4.2 ms, nearly 80 times faster. The first attempt at the second function printed 130 microseconds, because the optimizer saw a loop that adds a constant and replaced it with a multiplication; black_box hides the value from it so the loop really runs. Both are the same arithmetic, and the difference is pure coordination. This is an extreme case, since real critical sections do more than increment, but it is the right instinct: share results, not the work.

Recognizing a deadlock from outside

A hung program shows no output and no error, so the first question is whether it is deadlocked or just slow. A deadlocked process uses no CPU, and a debugger shows every blocked thread parked on a mutex. On Linux, gdb -p <pid> followed by thread apply all bt lists each thread's stack, and two stacks that end in lock inside each other's critical sections are the signature. On macOS, sample <pid> gives the same picture. Naming your threads with thread::Builder makes the trace readable (spawning and naming threads).

Reads dominate? Consider RwLock, then measure

RwLock<T> allows many readers or one writer. It looks like a free upgrade for read-heavy data, and often it is not: the bookkeeping costs more than a plain mutex when the critical section is short, and a stream of readers can starve a writer on some platforms. Reach for it when readers hold the lock for a meaningful time, and benchmark against Mutex first (atomics are better still for a single number).

When not to share state

  • If the threads only report results, send them over a channel and let one thread own the data (message passing). There is no lock to hold too long.
  • If each thread can build its own partial result, merge at the end. Eight threads that each count locally and add once beat eight threads that lock a counter 1,000 times.
  • If the shared value is one integer or a flag, use an atomic.
Also inRust
Taggedarcmutex
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.