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 TRefCell: the runtime borrow count ensures no active&Tborrows exist → safe to create&mut TCell: copiesTin/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:
| Type | Thread-safe? | Mechanism | Overhead | When to use |
|---|---|---|---|---|
Cell<T> | No | Copies values in/out (requires T: Copy) | Zero runtime cost | Single-threaded, small Copy types (counters, flags) |
RefCell<T> | No | Runtime borrow counting; panics on violation | Borrow count check per access | Single-threaded, need &mut T through &self |
Mutex<T> | Yes | OS or async lock; one accessor at a time | Lock acquisition | Multi-threaded, exclusive access |
RwLock<T> | Yes | Multiple readers or one writer | Lock acquisition | Multi-threaded, read-heavy workloads |
Atomic* | Yes | Hardware atomic instructions (CAS — Compare-And-Swap, load/store) | CPU fence instructions | Multi-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:
- Looks for
some_methodon the wrapper type itself. - If not found, calls
Deref::deref(&self) -> &Targetto get a reference to the inner type, and looks forsome_methodon&Target. - If the method requires
&mut self, the compiler needsDerefMut::deref_mut(&mut self) -> &mut Targetinstead.
This means which traits a wrapper implements determines what you can do through it:
| Wrapper | Implements Deref? | Implements DerefMut? | Effect |
|---|---|---|---|
Arc<T> | Yes → &T | No | Read-only access to inner T |
MutexGuard<'_, T> | Yes → &T | Yes → &mut T | Full read/write access |
Box<T> | Yes → &T | Yes → &mut T | Full read/write access |
When you write arc.push(4) on an Arc<Vec<i32>>:
- Compiler auto-derefs to
&Vec<i32>(viaDeref) Vec::pushrequires&mut self- Compiler would need
DerefMutto get&mut Vec<i32>, butArcdoes not implement it - Compile error
When you write guard.push(4) on a MutexGuard<'_, Vec<i32>>:
- Compiler auto-derefs to
&mut Vec<i32>(viaDerefMut) Vec::pushgets the&mut selfit 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 errorThe 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:
- Safe access — guards implement
Derefand/orDerefMut, giving you&Tor&mut Tto the protected data. You never touch the data directly; you always go through the guard. - 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. - 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:
| Primitive | Lock method | Guard type | Implements |
|---|---|---|---|
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() returns | LockResult<MutexGuard<'_, T>> | MutexGuard<'_, T> (via .await) |
| When contended | Parks 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 when | Lock is held briefly, or you’re not in async code | Lock is held across .await points, or you’re in async code |
| Poisoning | Yes — see below | No |
Don't use std::sync::Mutex in async code
Holding a
std::sync::Mutexguard across an.awaitpoint blocks the entire executor thread. Other tasks on that thread cannot make progress. Usetokio::sync::Mutexwhen the critical section contains.awaitpoints.
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) andRwLockvariants.
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:
- Sending across threads —
tokio::spawnrequiresSend - Returning from a function — the guard would outlive the borrow
- 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, notSend.lock_owned()= guard owns, can move anywhere, isSend.
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’sState, a struct field, a startup variable) with many clones. Thestrong_countis 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 itSend(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()) → returnsRwLockReadGuard, which implementsDeref→&T. Multiple readers can hold the lock simultaneously. - Write (
.write()) → returnsRwLockWriteGuard, which implementsDerefMut→&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 pattern | All access is exclusive | Many readers, few writers |
| Overhead | One lock/unlock per access | Slightly more — must track reader count |
| Starvation risk | None (FIFO queue) | Writers can starve if readers never stop (implementation-dependent) |
| Best for | Short critical sections, frequent writes | Read-heavy caches, configs, routing tables |
Rule of thumb
Start with
Mutex. Switch toRwLockwhen 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
| Situation | Type | Why |
|---|---|---|
Single-threaded, Copy type (counter, flag) | Cell<T> | Zero overhead, no runtime checks |
| Single-threaded, non-Copy type | RefCell<T> | Runtime borrow checking, panics on misuse |
| Single-threaded, shared ownership + mutation | Rc<RefCell<T>> (Rc = Reference Counted) | Cheap cloning + interior mutability |
| Multi-threaded, shared ownership + mutation | Arc<Mutex<T>> | Atomic refcount + lock |
| Multi-threaded, read-heavy workload | Arc<RwLock<T>> | Multiple concurrent readers |
| Multi-threaded, single primitive value | AtomicU64 / AtomicBool / etc. | Lock-free, hardware-level |
Async code, lock held across .await | Arc<tokio::sync::Mutex<T>> | Yields task instead of blocking thread |
Async code, read-heavy + across .await | Arc<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>>isSend + SyncbutRc<RefCell<T>>is not - Async — how
.awaityields vs how.lock()blocks