Skip to main content

Rust Shared-State Concurrency: Mutex and Arc Tutorial

Shared-state concurrency allows multiple threads to directly access and modify the same data. Unlike message passing, which sends data ownership between threads, shared-state concurrency requires synchronization primitives to prevent data races and race conditions. Rust provides Mutex<T> for mutual exclusion and Arc<T> for thread-safe shared ownership, allowing you to safely coordinate access to mutable data across threads without garbage collection or lock corruption.

Understanding Shared-State Concurrency

In shared-state concurrency, multiple threads hold references to the same data and coordinate their access. Two concepts are essential:

Mutual Exclusion (Mutex): A Mutex<T> acts as a lock that ensures only one thread can access the protected data at any given moment. Other threads block until the lock is released, preventing simultaneous mutations that would cause data corruption.

Atomic Reference Counting (Arc): An Arc<T> is a thread-safe variant of Rc<T> that uses atomic operations for reference counting. It allows multiple threads to own the same value without moving or copying it.

When to use shared-state vs. message passing

ScenarioUse Shared-StateReason
Multiple threads need frequent read/write access to shared dataMutex<T> + Arc<T>Direct access is simpler than channel coordination.
Threads do independent work, synchronize rarelyMessage passingCleaner separation of concerns; fewer lock contentions.
Implementing high-performance caches or countersMutex<T>Avoids channel overhead.
Producer-consumer pipelinesMessage passingNatural fit; ownership transfer via channels.

Using Mutex<T> to Protect Shared Data

A Mutex<T> guards data by allowing only one thread to access it at a time. The lock is acquired via the lock() method, which blocks until the lock is available.

use std::sync::Mutex;

fn main() {
let counter = Mutex::new(0);

// Acquire the lock and get a mutable reference to the data
let mut num = counter.lock().unwrap();
*num += 1;

println!("Counter: {}", *num); // Counter: 1
} // Lock is released when `num` goes out of scope

The .lock() method returns a Result<MutexGuard<T>, PoisonError>. MutexGuard is a smart pointer that:

  • Dereferences to the data via * operator.
  • Automatically releases the lock when dropped (goes out of scope).
  • Prevents dangling references to locked data—the guard must exist for the data to be accessed.
use std::sync::Mutex;

let value = Mutex::new(5);
{
let mut guard = value.lock().unwrap();
*guard += 10;
} // Lock released here when guard is dropped

// Safe to acquire lock again
let final_value = value.lock().unwrap();
println!("{}", *final_value); // 15

Combining Arc<T> and Mutex<T> for Multi-Threaded Sharing

To share mutable data across threads, combine Arc<T> (for shared ownership) and Mutex<T> (for synchronized access). Rc<T> is single-threaded and will not compile with threads; Arc<T> is the thread-safe alternative.

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

fn main() {
// Wrap the counter in Mutex for synchronized access,
// then wrap in Arc for shared thread-safe ownership
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

// Spawn 10 threads, each incrementing the counter
for _ in 0..10 {
let counter_clone = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter_clone.lock().unwrap();
*num += 1;
});
handles.push(handle);
}

// Wait for all threads to finish
for handle in handles {
handle.join().unwrap();
}

println!("Final count: {}", *counter.lock().unwrap()); // Final count: 10
}

Step-by-step breakdown

  1. Arc::new(Mutex::new(0)) — Create an atomic reference-counted mutex wrapping the counter.
  2. Arc::clone(&counter) — Clone the arc (not the data) for each thread. Arc clones are cheap; they only increment a reference count.
  3. counter_clone.lock().unwrap() — Acquire the lock, blocking if another thread holds it. unwrap() panics if the lock is poisoned (a thread panicked while holding the lock).
  4. *num += 1 — Safely modify the protected data.
  5. Auto-drop — When num leaves scope, the lock is released, allowing other threads to acquire it.

Common Patterns and Gotchas

Deadlocks

A deadlock occurs when a thread tries to acquire a lock it already holds, or when two threads wait for each other's locks. Rust cannot prevent logical deadlocks at compile time.

// DEADLOCK: Thread holds lock, tries to acquire it again
let mutex = Arc::new(Mutex::new(0));
let m = mutex.lock().unwrap();
let m2 = mutex.lock().unwrap(); // Blocks forever—current thread holds the lock

Prevention: Keep critical sections short; never try to acquire the same lock twice.

Poisoned locks

If a thread panics while holding a mutex lock, Rust marks the mutex as "poisoned" to prevent other threads from continuing with possibly corrupted data. Future lock attempts return Err.

let mutex = Arc::new(Mutex::new(vec![]));
{
let mut guard = mutex.lock().unwrap();
panic!("oops!");
// Lock is now poisoned
}

// Later, this returns an error
match mutex.lock() {
Ok(guard) => println!("Acquired lock"),
Err(poisoned) => println!("Lock was poisoned—data may be corrupted"),
}

Practice: Use unwrap() on lock results only in main; in libraries, use match or .unwrap_or_else() for graceful handling.

Performance considerations

Holding locks too long blocks other threads, causing contention and poor performance.

// BAD: Lock is held during expensive I/O
let mutex = Arc::new(Mutex::new(data));
let mut guard = mutex.lock().unwrap();
expensive_io_operation(); // Other threads blocked!
*guard = new_value;

// GOOD: Lock only around the shared data mutation
expensive_io_operation();
let mut guard = mutex.lock().unwrap();
*guard = new_value; // Lock released immediately after

Mutex vs. RefCell

FeatureMutex<T>RefCell<T>
Thread-safeYes (atomics)No
Borrow checkingRuntime (panics if locked)Runtime (panics on double-borrow)
Lock acquisitionBlockingImmediate
Use caseMulti-threaded shared stateSingle-threaded interior mutability

Key Takeaways

  • Mutex<T> provides mutual exclusion: only one thread accesses the data at a time; the lock is automatically released when the guard drops.
  • Arc<T> provides thread-safe shared ownership; each clone increments an atomic reference count.
  • Arc<Mutex<T>> is the standard pattern for shared mutable state across threads.
  • Keep critical sections short to avoid contention and potential deadlocks.
  • Handle poisoned locks gracefully; a poisoned lock indicates a panic occurred while holding it.
  • Atomic operations guarantee that reference counting itself is thread-safe; the data inside the mutex is protected by mutual exclusion.

Frequently Asked Questions

Why do I need to call .unwrap() on lock()?

lock() returns a Result because the mutex might be poisoned (a thread panicked while holding the lock). In most cases, you .unwrap() to panic if poisoned; in production code, handle the error gracefully.

What is the performance cost of Arc and Mutex?

Arc cloning is O(1) and uses atomic operations, which are extremely fast. Mutex::lock() involves atomic operations and may cause OS-level thread blocking, which is slower than unguarded access. Keep critical sections small.

Can I use Mutex instead of channels?

For simple shared-state coordination, yes. But channels are better for producer-consumer patterns and separating concerns. Shared-state is better for frequent read/write access to a small shared value.

What happens if I drop a MutexGuard early?

The lock is released immediately—useful for reducing critical section length:

{
let mut guard = mutex.lock().unwrap();
*guard = new_value;
} // Lock released here
// Safe code here; no lock held

Can multiple threads call lock() simultaneously?

Yes. All threads call lock() and block until the lock is available. The OS scheduler wakes them up one at a time. Only one thread executes the critical section at any moment.

Further Reading