Arc, Mutex, and Interior Mutability

What is interior mutability?

Rust’s borrow checker enforces at compile time that you have either one &mut T (exclusive mutable reference) or any number of &T (shared immutable references) — never both. This is the aliasing XOR mutability rule.

Interior mutability is a design pattern that lets you mutate data through a shared &T reference, bypassing the compile-time restriction by moving the check elsewhere (runtime, hardware atomics, or unsafe code). Every interior mutability type in Rust is built on a single primitive:

UnsafeCell — the foundation

UnsafeCell<T> is the only type in Rust that can legally convert &T to *mut T (a raw mutable pointer — like &mut T but without borrow checker guarantees; the caller is responsible for ensuring no aliasing). It tells the compiler “do not assume this data is immutable just because someone holds a & reference to it.” Without UnsafeCell, any mutation through &T would be undefined behavior — even with raw pointers.

The question is: how do types like MutexGuard return &mut T (a proper mutable reference with borrow checker guarantees) when UnsafeCell only gives *mut T (a raw pointer without guarantees)?

The answer is a two-step bridge inside an unsafe block:

// Step 1: UnsafeCell gives a raw pointer
let raw: *mut T = self.data.get();  // UnsafeCell::get(&self) -> *mut T
 
// Step 2: Convert raw pointer to mutable reference inside unsafe
unsafe { &mut *raw }
//       ^^^^^^^^^ *raw dereferences the pointer, &mut borrows it
//       This is sound ONLY if no other reference to T exists right now.

The unsafe block is where the programmer takes responsibility: “I guarantee nobody else is accessing this T right now.” Each interior mutability type provides that guarantee through a different mechanism:

  • Mutex: the lock ensures only one thread entered → only one *mut T → safe to create &mut T
  • RefCell: the runtime borrow count ensures no active &T borrows exist → safe to create &mut T
  • Cell: copies T in/out (never hands out references at all) → no aliasing possible

Here is a simplified view of how MutexGuard implements DerefMut:

impl<'a, T> DerefMut for MutexGuard<'a, T> {
    fn deref_mut(&mut self) -> &mut T {
        // The Mutex wraps an UnsafeCell<T> internally:
        let raw: *mut T = self.mutex.data.get(); // UnsafeCell::get()
        unsafe { &mut *raw }
        // Sound because: we hold the lock, so no other guard exists,
        // so no other &T or &mut T to this data can exist.
    }
}

The reason Mutex can do this but Arc cannot comes down to how they store data internally:

// Mutex stores data in UnsafeCell — it needs the *mut T path:
pub struct Mutex<T> {
    data: UnsafeCell<T>,  // ← can produce *mut T → &mut T
    // ...lock state...
}
 
// Arc stores data as plain T — no UnsafeCell, no mutation path:
struct ArcInner<T> {
    strong: AtomicUsize,
    weak: AtomicUsize,
    data: T,              // ← plain T, not UnsafeCell<T>
}

Arc’s Deref works through normal Rust rules: if you have &ArcInner<T> (a shared reference to the heap struct), then &self.inner().data gives you &T — a shared reference to the data field. No UnsafeCell is involved, no unsafe is needed. It’s just ordinary field access through a shared reference:

impl<T> Deref for Arc<T> {
    type Target = T;
    fn deref(&self) -> &T {
        &self.inner().data
        // self.inner() → &ArcInner<T>  (shared ref to the heap allocation)
        // .data        → accesses the T field
        // &...         → takes a shared reference: &T
        // No UnsafeCell, no unsafe, no *mut T. Just &T.
    }
}
// Arc does NOT implement DerefMut — and CAN'T, because data is plain T.
// To get &mut T you'd need UnsafeCell, and to use UnsafeCell safely
// you'd need to prove exclusive access — which Arc can't, because
// multiple clones exist.

All interior mutability types use UnsafeCell internally and wrap it with a safe API:

TypeThread-safe?MechanismOverheadWhen to use
Cell<T>NoCopies values in/out (requires T: Copy)Zero runtime costSingle-threaded, small Copy types (counters, flags)
RefCell<T>NoRuntime borrow counting; panics on violationBorrow count check per accessSingle-threaded, need &mut T through &self
Mutex<T>YesOS or async lock; one accessor at a timeLock acquisitionMulti-threaded, exclusive access
RwLock<T>YesMultiple readers or one writerLock acquisitionMulti-threaded, read-heavy workloads
Atomic*YesHardware atomic instructions (CAS — Compare-And-Swap, load/store)CPU fence instructionsMulti-threaded, primitive values only (bool, u64, usize, etc.)

See Refcell for RefCell<T> + Rc<T> patterns in single-threaded code.

This note focuses on Mutex<T> and the Arc<Mutex<T>> pattern for multi-threaded and async use.


How auto-deref works (Deref and DerefMut)

Before diving into Arc and Mutex, we need to understand how Rust resolves method calls on wrapper types — because the compiler errors you’ll hit all come down to which Deref trait a wrapper implements.

When you write wrapper.some_method(), the compiler:

  1. Looks for some_method on the wrapper type itself.
  2. If not found, calls Deref::deref(&self) -> &Target to get a reference to the inner type, and looks for some_method on &Target.
  3. If the method requires &mut self, the compiler needs DerefMut::deref_mut(&mut self) -> &mut Target instead.

This means which traits a wrapper implements determines what you can do through it:

WrapperImplements Deref?Implements DerefMut?Effect
Arc<T>Yes → &TNoRead-only access to inner T
MutexGuard<'_, T>Yes → &TYes → &mut TFull read/write access
Box<T>Yes → &TYes → &mut TFull read/write access

When you write arc.push(4) on an Arc<Vec<i32>>:

  • Compiler auto-derefs to &Vec<i32> (via Deref)
  • Vec::push requires &mut self
  • Compiler would need DerefMut to get &mut Vec<i32>, but Arc does not implement it
  • Compile error

When you write guard.push(4) on a MutexGuard<'_, Vec<i32>>:

  • Compiler auto-derefs to &mut Vec<i32> (via DerefMut)
  • Vec::push gets the &mut self it needs
  • Works

The problem: shared ownership blocks mutation

Arc<T> (Atomically Reference-Counted pointer) gives you shared ownership across threads — multiple Arc<T> clones all point to the same heap-allocated T. But because multiple owners exist simultaneously, Arc<T> can only give you &T, never &mut T. If it implemented DerefMut, two clones could produce simultaneous &mut T, violating the aliasing rule and causing data races.

use std::sync::Arc;
 
let data: Arc<Vec<i32>> = Arc::new(vec![1, 2, 3]);
let data2: Arc<Vec<i32>> = Arc::clone(&data);
// data and data2 both point to the same Vec on the heap
 
// ❌ compile error: cannot borrow data held in an Arc as mutable
data.push(4);
error[E0596]: cannot borrow data in an `Arc` as mutable
  --> src/main.rs:5:5
   |
5  |     data.push(4);
   |     ^^^^ cannot borrow as mutable
   |
   = help: trait `DerefMut` is required, but `Arc<Vec<i32>>` implements `Deref<Target = Vec<i32>>`

Adding mut to the binding does not help:

let mut data: Arc<Vec<i32>> = Arc::new(vec![1, 2, 3]);
data.push(4); // ❌ same error

The mut on the variable means “I can reassign data to point to a different Arc.” It says nothing about the Vec inside. The inner data is behind Arc’s Deref, which only gives &T.


The solution: Mutex

Mutex<T> (Mutual Exclusion) wraps a T and enforces at runtime that only one thread or task accesses the T at a time. Calling .lock() blocks (or yields, for async mutexes) until the lock is available, then returns a guard.

What is a guard?

A guard is an intermediate data structure that Rust’s synchronization primitives return when you acquire a lock. It is an RAII (Resource Acquisition Is Initialization) handle — a value whose creation acquires a resource (the lock) and whose destruction releases it. Guards serve three purposes:

  1. Safe access — guards implement Deref and/or DerefMut, giving you &T or &mut T to the protected data. You never touch the data directly; you always go through the guard.
  2. Automatic release — when the guard is dropped (goes out of scope or via explicit drop(guard)), it releases the lock. You cannot forget to unlock because the compiler manages the guard’s lifetime.
  3. Scope-limited critical section — the guard’s lifetime is the critical section. The lock is held exactly as long as the guard exists. This makes it easy to reason about how long you’re blocking other threads.

Every synchronization primitive in Rust returns its own guard type:

PrimitiveLock methodGuard typeImplements
Mutex<T>.lock()MutexGuard<'_, T>Deref + DerefMut
Mutex<T>.lock_owned()OwnedMutexGuard<T>Deref + DerefMut
RwLock<T>.read()RwLockReadGuard<'_, T>Deref
RwLock<T>.write()RwLockWriteGuard<'_, T>Deref + DerefMut

Read guards only implement Deref (shared &T), never DerefMut — multiple readers exist simultaneously, so exclusive access is impossible. Write guards and mutex guards implement both, because they guarantee exclusive access.

std::sync::Mutex vs tokio::sync::Mutex

There are two Mutex implementations you’ll encounter:

std::sync::Mutex<T>tokio::sync::Mutex<T>
.lock() returnsLockResult<MutexGuard<'_, T>>MutexGuard<'_, T> (via .await)
When contendedParks the OS thread — the thread sleeps until the lock is free. No other work runs on that thread.Yields the async task — the task suspends, and the executor runs other tasks on the same thread.
Use whenLock is held briefly, or you’re not in async codeLock is held across .await points, or you’re in async code
PoisoningYes — see belowNo

Don't use std::sync::Mutex in async code

Holding a std::sync::Mutex guard across an .await point blocks the entire executor thread. Other tasks on that thread cannot make progress. Use tokio::sync::Mutex when the critical section contains .await points.

What is mutex poisoning?

If a thread panics while holding a std::sync::Mutex lock, the Mutex becomes poisoned. All subsequent calls to .lock() return Err(PoisonError) instead of the guard.

Why? Because a panic means the code that was modifying the protected data did not finish. The data may be in an inconsistent state — half-updated, invariants broken. Poisoning forces every future caller to explicitly decide whether to proceed with potentially-corrupted data (via .lock().unwrap() or .lock().unwrap_or_else(|e| e.into_inner())) or to bail out.

tokio::sync::Mutex does not poison. In async code, a panicking task is typically caught by the executor (tokio::spawn returns a JoinError), and the common pattern is to restart the task or propagate the error. Poisoning adds complexity without matching the async error-handling model.

See Synchronization primitives for a deeper comparison including parking_lot::Mutex (spinlock + fallback) and RwLock variants.


Arc<Mutex<T>> — the combined pattern

Arc<Mutex<T>> = shared ownership (Arc) + safe mutation (Mutex):

use std::sync::Arc;
use tokio::sync::Mutex;
 
let data: Arc<Mutex<Vec<i32>>> = Arc::new(Mutex::new(vec![1, 2, 3]));
//        ^^^^^^^^^^^^^^^^^^^
//        Arc: "I can clone this cheaply and share across tasks"
//        Mutex: "only one task may hold &mut Vec<i32> at a time"
 
let data2 = Arc::clone(&data); // cheap: increments atomic counter
 
// In task 1:
let mut guard = data.lock().await;
guard.push(4); // ✅ DerefMut gives &mut Vec<i32>
// guard dropped here → lock released
 
// In task 2:
let guard2 = data2.lock().await; // waits if task 1 still holds the lock
println!("{:?}", *guard2); // Deref gives &Vec<i32>

Why mut goes on the guard, not the Arc

// No 'mut' needed on 'state' — we never reassign the Arc itself
let state: Arc<Mutex<HashMap<String, String>>> = Arc::new(Mutex::new(HashMap::new()));
 
// 'mut' is on the guard — .insert() goes through DerefMut on the guard
let mut map = state.lock().await;
map.insert("key".to_string(), "value".to_string()); // ✅

Forgetting mut on the guard:

let map = state.lock().await; // no mut
map.insert("key".to_string(), "value".to_string()); // ❌
error[E0596]: cannot borrow `map` as mutable, as it is not declared as mutable
  --> src/main.rs:7:5
   |
6  |     let map = state.lock().await;
   |         --- help: consider changing this to be mutable: `mut map`
7  |     map.insert("key".to_string(), "value".to_string());
   |     ^^^ cannot borrow as mutable

DerefMut requires &mut self on the guard — so the guard binding itself must be mut.


lock() vs lock_owned() — the lifetime difference

lock() — guard borrows from the Mutex

// Signature (simplified):
impl<T> Mutex<T> {
    async fn lock(&self) -> MutexGuard<'_, T>
    //             ^^^^^   ^^^^^^^^^^^^^^^^^^
    //          borrows self   guard's lifetime tied to self
}

The guard cannot outlive the reference you locked through. Fine when the Mutex is in scope:

async fn update(state: &Arc<Mutex<Vec<i32>>>) {
    let mut guard = state.lock().await;
    guard.push(42);
    // guard dropped here → lock released
}

lock_owned() — guard owns a clone of the Arc

// Signature (simplified):
impl<T> Mutex<T> {
    async fn lock_owned(self: Arc<Self>) -> OwnedMutexGuard<T>
    //                  ^^^^^^^^^^^^       ^^^^^^^^^^^^^^^^^
    //               takes Arc by clone    no lifetime parameter — self-contained
}

OwnedMutexGuard<T> internally holds an Arc<Mutex<T>>, so it has no lifetime parameter and is Send (Send is a marker trait indicating a type can be safely transferred to another thread) when T: Send.

The '_ lifetime in MutexGuard<'_, T> means the guard is borrowing the Mutex. This has two consequences:

1. The guard cannot outlive the Mutex reference

If the Mutex is behind a local Arc and you try to return the guard from a function, the borrow checker stops you — the Arc (and thus the Mutex reference) dies at the end of the function, but the guard would still be alive in the caller:

// Does NOT compile:
async fn get_guard(state: Arc<Mutex<Vec<i32>>>)
    -> MutexGuard<'_, Vec<i32>>  // lifetime tied to... what?
{
    state.lock().await  // borrows `state`, which is dropped at fn end
}
// error: cannot return value referencing function parameter `state`

lock_owned() fixes this because the guard owns its Arc — dropping the original state is fine:

// Compiles — OwnedMutexGuard has no lifetime:
async fn get_guard(state: Arc<Mutex<Vec<i32>>>)
    -> OwnedMutexGuard<Vec<i32>>
{
    state.lock_owned().await
    // Arc cloned into the guard; the guard keeps the Mutex alive
}

2. MutexGuard is not Send

MutexGuard<'_, T> borrows from the Mutex — if you send the guard to another thread, the borrow would cross thread boundaries, and the original thread might drop the Mutex while the other thread still holds the guard. So MutexGuard does not implement Send.

This matters with tokio::spawn, which requires the future to be Send (Tokio may move it between worker threads):

async fn spawn_task(state: Arc<Mutex<Vec<i32>>>) {
    let guard = state.lock().await;
 
    tokio::spawn(async move {
        println!("{:?}", *guard); // ❌
    });
}
error[E0277]: `MutexGuard<'_, Vec<i32>>` cannot be sent between threads safely
   --> src/main.rs:9:5
    |
9   |     tokio::spawn(async move {
    |     ^^^^^^^^^^^^ `MutexGuard<'_, Vec<i32>>` cannot be sent between threads safely
    |
    = help: the trait `Send` is not implemented for `MutexGuard<'_, Vec<i32>>`

lock_owned() fixes this — OwnedMutexGuard<T> is self-contained (no borrows), so it is Send:

async fn spawn_task(state: Arc<Mutex<Vec<i32>>>) {
    let guard = state.lock_owned().await;
    // OwnedMutexGuard — no lifetime, Send when T: Send
 
    tokio::spawn(async move {
        println!("{:?}", *guard); // ✅
    });
}

When do you actually need owned guards?

Most of the time, you don’t. If the guard lives and dies in the same scope — lock, read or mutate, drop — the borrowed lock() is always sufficient and cheaper (no atomic increment for the Arc clone).

Reach for lock_owned() only when you hit one of three walls:

  1. Sending across threadstokio::spawn requires Send
  2. Returning from a function — the guard would outlive the borrow
  3. Storing in a struct — you’d need a lifetime parameter otherwise

In practice, the borrow checker tells you: if lock() doesn’t compile, switch to lock_owned().

Summary

lock() = guard borrows, stays local, not Send. lock_owned() = guard owns, can move anywhere, is Send.

Why does lock_owned() clone the Arc?

Not primarily to keep the Mutex alive — in practice, Arc<Mutex<T>> is long-lived application state (Axum’s State, a struct field, a startup variable) with many clones. The strong_count is typically 5–10, not 2. The Mutex outlives any single guard.

The real motivation is type-system correctness: by owning an Arc, the guard has no lifetime parameter and no borrows. This makes it Send (safe to move between threads) and self-contained (can be returned from functions, stored in structs). The Arc clone is the mechanism that gives the guard these type-level properties. It also provides a mechanical guarantee — even if the immediate caller drops, the guard’s Arc prevents the Mutex from being freed — but satisfying the borrow checker is the primary reason it exists.


The guard does not give you ownership

A common mistake: trying to call a method that consumes the inner value through a guard. The guard gives you &mut T via DerefMut, not owned T. Consider a shared collection of Question structs (any Clone type would exhibit the same behavior):

async fn list_values(state: Arc<Mutex<HashMap<String, Question>>>) -> Vec<Question> {
    let guard = state.lock_owned().await;
    guard.into_values().collect() // ❌
}
error[E0507]: cannot move out of dereference of `OwnedMutexGuard<HashMap<String, Question>>`
  --> src/main.rs:4:5
   |
4  |     guard.into_values().collect()
   |     ^^^^^ move occurs because value has type `HashMap<String, Question>`,
   |           which does not implement the `Copy` trait

into_values() takes self — it consumes the HashMap. The guard only dereferences to &mut HashMap; the HashMap still lives inside the Mutex and cannot be moved out.

If you need owned values, clone:

// Clone the entire HashMap, then consume the clone:
let owned: HashMap<String, Question> = guard.clone();
owned.into_values().collect()
 
// Or clone elements one at a time (preferred — avoids cloning the HashMap structure):
guard.values().cloned().collect::<Vec<Question>>()
// values()  → Iterator<Item = &Question>
// cloned()  → Iterator<Item = Question>  (clones each element)
// collect() → Vec<Question>

Why can you clone something you don’t own?

This might feel contradictory: we just said the guard doesn’t give you ownership, yet cloned() produces owned Question values. The key is the signature of Clone::clone:

pub trait Clone {
    fn clone(&self) -> Self;
}

clone() takes &self — a shared reference. It doesn’t consume the original; it reads the data and constructs a new, independent copy. So guard.values() gives Iterator<Item = &Question>, and cloned() calls Question::clone() on each &Question, producing owned Question values. You never need ownership of T to clone it — you only need &T and T: Clone.


RwLock<T> — multiple readers or one writer

Mutex<T> is simple: one accessor at a time, period. But many workloads are read-heavy — a configuration map read by every request handler, a cache checked thousands of times per second, a routing table consulted on every packet. Forcing readers to wait for each other is wasteful when none of them are modifying the data.

RwLock<T> (Reader-Writer Lock) splits access into two modes:

  • Read (.read()) → returns RwLockReadGuard, which implements Deref&T. Multiple readers can hold the lock simultaneously.
  • Write (.write()) → returns RwLockWriteGuard, which implements DerefMut&mut T. Exclusive — blocks all readers and other writers.

This maps directly to Rust’s aliasing rule: many &T or one &mut T.

use std::sync::Arc;
use tokio::sync::RwLock;
 
let config: Arc<RwLock<HashMap<String, String>>> =
    Arc::new(RwLock::new(HashMap::new()));
 
// Multiple tasks can read simultaneously:
let read_guard = config.read().await;
//  read_guard: RwLockReadGuard<'_, HashMap<...>>
//  Deref → &HashMap  (shared, read-only)
println!("entries: {}", read_guard.len());
// Other tasks can also call .read() — no blocking.
drop(read_guard);
 
// Only one task can write, and it excludes all readers:
let mut write_guard = config.write().await;
//  write_guard: RwLockWriteGuard<'_, HashMap<...>>
//  DerefMut → &mut HashMap  (exclusive)
write_guard.insert("key".into(), "value".into());
// All .read() and .write() calls block until this guard drops.

read_owned() and write_owned() — same pattern as Mutex

Just like Mutex has lock() (borrowed) and lock_owned() (owned), RwLock has the same split:

// Borrowed guards (lifetime-bound, not Send):
let read_guard = config.read().await;       // RwLockReadGuard<'_, T>
let write_guard = config.write().await;     // RwLockWriteGuard<'_, T>
 
// Owned guards (no lifetime, Send — same pattern as Mutex):
let read_guard = config.clone().read_owned().await;
//  OwnedRwLockReadGuard<HashMap<...>>
//  Owns an Arc<RwLock<T>> internally — Send, no lifetime.
 
let write_guard = config.clone().write_owned().await;
//  OwnedRwLockWriteGuard<HashMap<...>>
//  Owns an Arc<RwLock<T>> internally — Send, no lifetime.

The motivation is identical to Mutex::lock_owned(): the owned guards carry their own Arc<RwLock<T>>, making them Send and lifetime-free. The same rule applies: use the borrowed versions by default (read(), write()). Only reach for owned variants when you need to send the guard across threads, return it from a function, or store it in a struct without a lifetime parameter.

Downgrading: write → read without releasing

You can downgrade a write guard to a read guard atomically. This is useful when you need to mutate, then immediately read the result without any other writer sneaking in between:

// Start with exclusive write access:
let mut write_guard = config.write().await;
write_guard.insert("new_key".into(), "new_value".into());
 
// Downgrade to a read guard WITHOUT releasing the lock:
let read_guard = write_guard.downgrade();
//  RwLockWriteGuard → RwLockReadGuard
//  No gap where another writer could sneak in.
//  Other readers CAN now acquire the lock.
println!("after insert: {}", read_guard.len());

There is no upgrade() (read → write). Upgrading would require blocking while other readers finish, which risks deadlock if two readers both try to upgrade simultaneously. If you need to upgrade, drop the read guard and acquire a write guard — accepting that another writer might intervene.

When to use RwLock vs Mutex

Mutex<T>RwLock<T>
Access patternAll access is exclusiveMany readers, few writers
OverheadOne lock/unlock per accessSlightly more — must track reader count
Starvation riskNone (FIFO queue)Writers can starve if readers never stop (implementation-dependent)
Best forShort critical sections, frequent writesRead-heavy caches, configs, routing tables

Rule of thumb

Start with Mutex. Switch to RwLock when profiling shows reader contention is a bottleneck. The added complexity of reader/writer semantics isn’t worth it unless you have a measurable read-heavy workload.


When to use what

SituationTypeWhy
Single-threaded, Copy type (counter, flag)Cell<T>Zero overhead, no runtime checks
Single-threaded, non-Copy typeRefCell<T>Runtime borrow checking, panics on misuse
Single-threaded, shared ownership + mutationRc<RefCell<T>> (Rc = Reference Counted)Cheap cloning + interior mutability
Multi-threaded, shared ownership + mutationArc<Mutex<T>>Atomic refcount + lock
Multi-threaded, read-heavy workloadArc<RwLock<T>>Multiple concurrent readers
Multi-threaded, single primitive valueAtomicU64 / AtomicBool / etc.Lock-free, hardware-level
Async code, lock held across .awaitArc<tokio::sync::Mutex<T>>Yields task instead of blocking thread
Async code, read-heavy + across .awaitArc<tokio::sync::RwLock<T>>Concurrent readers + yields on contention
Async code, lock held briefly (no .await inside)Arc<std::sync::Mutex<T>>Lower overhead than async Mutex

See also

  • Refcell — single-threaded interior mutability (RefCell<T> + Rc<T>)
  • Synchronization primitives — std vs parking_lot vs tokio lock comparison, spinlock internals
  • Send and Sync — why Arc<Mutex<T>> is Send + Sync but Rc<RefCell<T>> is not
  • Async — how .await yields vs how .lock() blocks