Cell, RefCell, and Mutex: Interior Mutability

On this page

Interior mutability allows objects accessible via shared references to modify part of their internal state, such as caches, counters, and synchronization queues. It does not negate the requirement that "conflicting accesses cannot coexist"; rather, the encapsulation chooses a different access protocol that can be proven safe.

The general immutability constraint of shared references has exceptions for parts wrapped in UnsafeCell. However, UnsafeCell itself does not provide locking, does not allow arbitrarily creating mutually aliased &muts, and does not allow data races. UnsafeCell

Cell<T>Move out, replace, or copy valuesRefCell<T>Dynamic borrow guardsMutex<T>Mutex guardsNo shared-value borrowsget requires Copy, replace does notReaders or exclusive writersConflicts return errors or panicOne lock holder at a timeContention waits or try_lock failsDifferent shared entry points have different internal access protocolsUnsafeCell is the underlying primitive; safe interfaces must maintain aliasing and synchronization rules themselves.

Cell is Not Exclusive to Copy Types

Cell::get requires T: Copy because it returns a copy of the internal value. set and replace can be used with non-Copy types; replace moves the old value out and puts a new one in, without exposing internal borrows. take replaces the value with a default one when T: Default, and into_inner consumes the Cell and retrieves the T. Cell

use std::cell::Cell;

fn main() {
    let name = Cell::new(String::from("before"));
    let old = name.replace(String::from("after"));
    assert_eq!(old, "before");
    assert_eq!(name.take(), "after");
    assert_eq!(name.into_inner(), "");
}

Thus, Cell<String> is valid and useful, even though you cannot call get. It lacks the dynamic borrow counting of RefCell, and it is not equivalent to operations being absolutely zero-cost: replacing a String may still involve resource management.

Saying "Cell never returns references" is too absolute. When holding &mut Cell<T>, get_mut can return &mut T; in this case, exclusive borrowing is already guaranteed by the compiler, requiring no additional runtime protocol.

Single-threaded code must also consider observing intermediate states

Temporarily taking out a Vec using Cell::take, processing it, and then setting it back seems to avoid borrow guards; but if reentrant external code is called during processing, other entry points might see the default empty Vec. If a panic occurs during processing, the taken value might be dropped directly, leaving the original Cell empty. Cell guarantees that such access is memory-safe, but does not guarantee that "take—process—put back" has transactional semantics.

Therefore, Cell is better suited for data that inherently allows whole-value replacement and where intermediate states have clear meaning. If you must temporarily borrow internal structures, RefCell can more directly express exclusivity during access; if operations might fail, you should calculate the new value before committing, or design recoverable state transitions.

RefCell Records Dynamic Borrows via Guards

borrow returns Ref<T>, and borrow_mut returns RefMut<T>. Generally, multiple shared borrows are allowed, or one exclusive borrow. The borrow only ends when the guard is dropped; you cannot assume the guard automatically becomes invalid just because "the last read value has passed."

use std::cell::RefCell;

fn main() {
    let values = RefCell::new(vec![1, 2]);
    let reading = values.borrow();
    assert!(values.try_borrow_mut().is_err());
    assert_eq!(reading.len(), 2);
    drop(reading);
    values.borrow_mut().push(3);
    assert_eq!(&*values.borrow(), &[1, 2, 3]);
}

borrow/borrow_mut panic on conflict; try_borrow/try_borrow_mut return the conflict as a Result and do not wait for another borrow to end. Commonly overlooked sources of conflict include callback reentrancy and temporary guards that live across the entire match or other long expressions. RefCell

RefCell<T> can be moved to another thread as a whole if T: Send, but it is not Sync. Rc<RefCell<T>> is single-threaded shared ownership plus dynamic borrowing, not a combination that can be shared across threads.

When returning references to internal fields, return the guard together

use std::cell::{Ref, RefCell};
struct Profile {
    fields: RefCell<(String, u32)>,
}
impl Profile {
    fn name(&self) -> Ref<'_, str> {
        Ref::map(self.fields.borrow(), |fields| fields.0.as_str())
    }
}
fn main() {
    let profile = Profile { fields: RefCell::new(("Li".into(), 7)) };
    let name = profile.name();
    assert_eq!(&*name, "Li");
    assert!(profile.fields.try_borrow_mut().is_err());
    drop(name);
    profile.fields.borrow_mut().0.push('!');
    assert_eq!(&*profile.name(), "Li!");
}

You cannot create a temporary Ref in a function and then return the &str inside it as a normal reference without a guard; the dynamic borrow credential would disappear when the function ends. Ref::map projects the visibility scope of the guard to the field while preserving the shared borrow state of the entire RefCell. Even if you only read name, you cannot simultaneously borrow the exclusive numeric field through the same RefCell; mapping does not automatically split one RefCell into two independent borrowing units. Ref::map

End dynamic borrows before calling external code

use std::cell::RefCell;
fn notify(queue: &RefCell<Vec<u32>>, callback: impl FnOnce(usize)) {
    let count = {
        let reading = queue.borrow();
        reading.len()
    };
    callback(count);
}
fn main() {
    let queue = RefCell::new(vec![1, 2]);
    notify(&queue, |count| {
        assert_eq!(count, 2);
        queue.borrow_mut().push(3);
    });
    assert_eq!(&*queue.borrow(), &[1, 2, 3]);
}

Here, the required scalar is extracted first, the guard is released, and then the callback is called, so the callback can modify the queue. If borrow_mut is called while reading is still alive, a borrow conflict will occur. The cost is that count only represents the state at the time of retrieval and is no longer guaranteed to equal the queue's current length after the callback executes; if business logic requires stable state, you should restrict what the callback can do, or change it to submitting events for outer-layer processing.

Do not rely on "single-threaded means no contention" to ignore reentrancy. Single-threaded code does not execute two machine instruction streams simultaneously, but external callbacks can re-enter the same object before the original operation is complete.

Does the guard remain after the last read?

Reading and dropping a guard are deliberately separate actions. Obtain a Ref, read once, then request RefMut: it still fails. Exclusive borrowing becomes possible only after every shared guard is released. This is why ordinary-reference last-use reasoning does not directly release Ref or MutexGuard.

Preparing the visual
Does the guard remain after the last read?

Dynamic borrowing follows guard lifetime, not the last read.

Mutex Extends Exclusive Access Across Threads

std::sync::Mutex::lock waits to acquire the lock and returns LockResult<MutexGuard<T>>. The guard's release unlocks; the lock protects data access following the same protocol and cannot allow some code to bypass the lock while expecting consistency.

try_lock does not block and returns a TryLockResult, which is a Result where errors distinguish between WouldBlock and Poisoned. The following verifies the contention branch while another thread holds the lock, avoiding undefined behavior from recursive locking in the same thread:

use std::sync::{Mutex, TryLockError, mpsc};
use std::thread;

fn main() {
    let value = Mutex::new(1);
    thread::scope(|scope| {
        let (ready_tx, ready_rx) = mpsc::channel();
        let (done_tx, done_rx) = mpsc::channel();
        let shared = &value;
        scope.spawn(move || {
            let mut guard = shared.lock().unwrap();
            ready_tx.send(()).unwrap();
            done_rx.recv().unwrap();
            *guard += 1;
        });
        ready_rx.recv().unwrap();
        assert!(matches!(value.try_lock(), Err(TryLockError::WouldBlock)));
        done_tx.send(()).unwrap();
    });
    assert_eq!(*value.lock().unwrap(), 2);
}

Poisoning reminds callers that a lock-holding thread previously left the critical section during panic unwinding, and business state may be incomplete. It is a suggestive check, not a complete safety proof; when recovering, you must evaluate invariants, cannot ignore them unconditionally, and cannot let unsafe code rely solely on poisoning to avoid UB. Mutex

The underlying implementation depends on the platform and standard library version, so you cannot definitively say it is always pthread_mutex_t on Linux. Choosing it relies primarily on its mutual exclusion contract, rather than internal fields of a specific implementation.

Acquire guardCheck balance and target limitCalculate new stateValidate before commitCommit both fieldsVisible after unlockThe same lock protects one complete state transitionOther participants only see the ledger before or after commitPrerequisite: All related reads and modifications follow this lock's protocolMutual exclusion ensures the critical section is not interleaved; business validation, no modification on error, and external side effects still need separate design.

Locks Protect Invariants, Not Just Variables

The following uses one lock to protect two account balances. Transfers must maintain the total, and retain the original state if the balance is insufficient or the target overflows. Calculating all new values first, then committing, avoids the scenario where the first field is modified but the second field's validation fails.

use std::sync::Mutex;
#[derive(Debug, PartialEq, Eq)]
enum TransferError { Insufficient, Overflow }
fn transfer(balances: &Mutex<(u64, u64)>, amount: u64)
    -> Result<(), TransferError>
{
    let mut state = balances.lock().unwrap();
    let left = state.0.checked_sub(amount).ok_or(TransferError::Insufficient)?;
    let right = state.1.checked_add(amount).ok_or(TransferError::Overflow)?;
    *state = (left, right);
    Ok(())
}
fn main() {
    let balances = Mutex::new((100, 20));
    assert_eq!(transfer(&balances, 30), Ok(()));
    assert_eq!(*balances.lock().unwrap(), (70, 50));
    assert_eq!(transfer(&balances, 80), Err(TransferError::Insufficient));
    assert_eq!(*balances.lock().unwrap(), (70, 50));
    let full = Mutex::new((10, u64::MAX));
    assert_eq!(transfer(&full, 1), Err(TransferError::Overflow));
    assert_eq!(*full.lock().unwrap(), (10, u64::MAX));
}

The example treats poisoning as an unrecoverable error and unwraps, which is suitable for demonstrating arithmetic and commit order; actual interfaces can return lock errors separately or define recovery strategies. If each balance has its own lock, unlocking them sequentially exposes intermediate states; holding two locks simultaneously requires a unified locking order to avoid deadlocks. Two atomic integers do not automatically constitute one atomic transfer.

Locks should contain the reads and commits necessary to maintain invariants, but slow I/O, unknown callbacks, or long calculations should be included cautiously. Moving calculations outside the lock shortens lock-holding time, but you must check if the old state the calculation depends on has changed; if necessary, record the version and verify after re-locking, do not assume calculations outside the lock still correspond to the latest data.

Poisoning Does Not Roll Back Already Written Data

PoisonError can return the guard, giving the caller a chance to check and repair the state. Calling into_inner merely retrieves the access credential; clear_poison clears the flag, neither of which automatically restores business invariants. The following constructs a controlled thread panic, then replaces the corrupted state with a known complete state.

use std::sync::Mutex;
use std::thread;
fn main() {
    let state = Mutex::new((50u32, 50u32));
    thread::scope(|scope| {
        let shared = &state;
        let failed = scope.spawn(move || {
            let mut guard = shared.lock().unwrap();
            guard.0 = 0;
            panic!("Simulate updating only one field");
        });
        assert!(failed.join().is_err());
    });
    assert!(state.is_poisoned());
    {
        let mut guard = match state.lock() {
            Ok(_) => panic!("Expected poisoned state"),
            Err(error) => error.into_inner(),
        };
        assert_eq!(*guard, (0, 50));
        *guard = (50, 50); // In teaching, known trusted complete state; actual recovery must have trusted basis.
        state.clear_poison();
    }
    assert!(!state.is_poisoned());
    assert_eq!(*state.lock().unwrap(), (50, 50));
}

This example relies on panic unwinding and observing thread failure via join; under panic=abort, the process terminates, and recovery via this flow is impossible. Real-world balances cannot be reset out of thin air; they require trusted snapshots, logs, or business reconciliation. Poisoning detection itself is not a reliability boundary that is necessarily triggered in all panic scenarios. Mutex poisoning

Selection Boundaries for the Three

QuestionCellRefCellMutex
Modify via &selfSupports replacement operationsVia dynamic borrowingVia lock-holding guard
Access conflictsDoes not maintain standard shared borrowsReturns borrow error or panicWaits or try_lock returns error
Whole Send when T: SendYesYesYes
Can implement SyncNoNoYes when T: Send

When only replacing the whole value, Cell is often simpler; when you need to temporarily borrow internal structures and limit to single-threaded, RefCell is more suitable; when maintaining invariants across multiple fields between threads, Mutex is usually easier to reason about. For asynchronous scenarios, you must also consider whether waiting blocks the executor and whether guards cross await, see Tokio runtime.