Skip to content

The Rust Engineering Handbook / Chapter 10

Interior Mutability

Choose runtime borrow checks, synchronization, or one-time initialization only when the shared-access contract requires them.

The method takes &self; the failure still belongs somewhere

A local ledger offers reads through a shared reference. Counting those reads should not force every caller to obtain &mut LocalLedger, and posting an entry must coexist with other owners of the ledger in one thread:

use std::cell::{Cell, RefCell};

#[derive(Default)]
struct LocalLedger {
    entries: RefCell<Vec<i64>>,
    reads: Cell<usize>,
}

impl LocalLedger {
    fn post(&self, cents: i64) {
        self.entries.borrow_mut().push(cents);
    }

    fn total(&self) -> i64 {
        self.reads.set(self.reads.get() + 1);
        self.entries.borrow().iter().sum()
    }
}

The two fields mutate through &self, but they make different promises. reads is copied out and replaced; no caller receives a reference into it. entries lends dynamically checked guards, so an overlapping mutable borrow can panic. Neither type can be shared safely between threads.

The signature has not made conflict disappear. It has chosen where conflict is detected and what the caller may observe. That choice—not the desire to silence a borrow error—is the center of interior mutability. The mechanism should match the data shape, the thread model, the failure policy, and the smallest complete invariant.

UnsafeCell is the language-level primitive

Ordinarily, safe code treats data reached through &T as not directly mutable. UnsafeCell<T> is the primitive that marks storage whose value may be mutated through shared access under an enclosing contract. Standard types such as Cell, RefCell, and synchronization primitives build safe interfaces around it.

UnsafeCell does not supply borrow checking, locking, atomicity, or thread safety. Calling UnsafeCell::get yields a raw pointer, and dereferencing it safely requires manually proving aliasing, validity, synchronization, and reentrancy obligations. Application code should almost always use a standard safe abstraction. A custom wrapper needs a safety case and belongs under the unsafe governance developed in Part XI.

The important semantic distinction is that interior mutability is explicit in the representation. It does not abolish reference rules; it defines which mutation behind shared access is permitted and how conflicts are controlled.

Cell<T> works by refusing to lend the interior

Cell::get requires T: Copy; replace, take, and related operations move values according to their bounds. The key fact is not that a cell is a tiny box. It is that safe callers cannot hold an ordinary reference into its contents while another caller replaces them. With no interior references to coordinate, Cell needs no dynamic borrow counter.

That makes the ledger’s read count a good fit. The counter is compact, local to one thread, and meaningful only as a whole value. A Cell is not atomic and is not Sync, so the compiler rejects an attempt to share this ledger across threads. Even within one thread, get followed by set is not a transaction: do not put a callback between those operations and assume the value remained unchanged.

RefCell<T> checks shared and exclusive borrows at runtime

RefCell<T> provides borrow() and borrow_mut() through &self. It tracks whether the contents are:

  • unborrowed;
  • shared-borrowed by one or more Ref guards;
  • exclusively borrowed by one RefMut guard.

Conflicting calls panic. The try_borrow and try_borrow_mut variants return an error instead and are often better when conflict is a recoverable condition.

The ledger says callers can share &LocalLedger, while each method dynamically proves access to entries. That can be a sound contract in a single-threaded graph, test fake, GUI model, or recursive structure whose access schedule cannot be expressed conveniently with ordinary borrows.

The panic path is not theoretical:

let values = RefCell::new(vec![1, 2]);
let shared = values.borrow();
let exclusive = values.borrow_mut(); // panics

The shared guard remains live through its later uses and drop. If the conflict is a caller-controlled or recoverable condition, try_borrow and try_borrow_mut expose it as a result instead. If the type relies on an internal proof that conflict cannot occur, keep that proof visible in the method structure. A dynamic-borrow panic is an invariant failure, not an acceptable substitute for API design.

Figure 10-1 puts enforcement and failure on the same map. RefCell tracks borrow state within one thread; locks coordinate threads and add blocking, poisoning, and liveness concerns.

A decision map starts with whether mutation can use exclusive ownership, then branches to Cell for small copyable values, RefCell for single-thread dynamic borrowing, OnceCell or OnceLock for one-time initialization, and Mutex or RwLock for synchronized shared state. A RefCell state machine shows Unborrowed transitioning to Shared count one or more and Exclusive count one, with conflicting transitions labeled panic or try_borrow error. Lock branches show contention and deadlock warnings.
Interior mutability changes where conflicts are detected. Select the smallest state machine that matches the invariant and make its runtime failure or blocking behavior part of the API contract.

Reentrancy is the hidden local concurrency

Single-threaded does not mean one uninterrupted call stack. A method may invoke a callback, formatter, observer, destructor, or event loop that reenters the object while a RefMut guard is active. The nested borrow then panics.

Suppose posting to the ledger also notifies an observer:

let mut entries = self.entries.borrow_mut();
entries.push(value);
self.notify_observers(); // an observer may borrow entries again

The code looks sequential, yet notify_observers can call total, which tries to borrow entries while the mutable guard is live. Finish the mutation, restore the ledger invariant, and release the guard before calling code the ledger does not control:

{
    let mut entries = self.entries.borrow_mut();
    entries.push(value);
}
self.notify_observers();

If the observer needs details, build an owned event or copy the small facts it needs before releasing the guard. Do not pass a borrowed view that recreates the same lifetime coupling. The scope is part of the proof: external code runs only after the protected state is coherent and available again.

Crossing a thread boundary changes the contract

Making LocalLedger concurrent is not a matter of changing RefCell to Mutex until the compiler agrees. The design must first decide which operations are atomic together. If posting and reading a total act on one vector, one mutex around that vector may honestly protect the invariant. If independent routes never participate in one atomic update, sharding can let them progress separately:

let shards = [Mutex::new(0_i64), Mutex::new(0_i64)];
*shards[route].lock().expect("counter lock is healthy") += delta;

Sharding can align one lock with one invariant domain instead of serializing the whole service. It also makes cross-shard operations harder: an atomic logical transfer may need a lock order, a coordinator, message passing, or a different data model.

A mutex permits threads sharing &Mutex<T> to obtain exclusive access through a guard. The guard’s scope is the synchronization boundary. Standard-library mutexes add blocking, contention, poisoning policy, and deadlock risk; the primitive’s documentation defines its memory-ordering effects. As with RefCell, release the guard before invoking callbacks or formatting arbitrary values. Reentrancy that produced a borrow panic locally can become a deadlock once the boundary is a non-reentrant mutex.

RwLock<T> permits multiple readers or one writer. It is not automatically faster for read-heavy work. Reader bookkeeping, writer starvation policy, platform implementation, critical-section length, and cache behavior determine results. Begin with a mutex when one lock is appropriate, then measure before adopting a read-write lock.

Do not hold a blocking std::sync guard across .await; suspension can block executor progress and create liveness failures. Async synchronization belongs to the runtime and cancellation contract discussed in Part X.

One-time cells encode an initialization state

OnceCell<T> supports one-time initialization in single-threaded contexts; OnceLock<T> provides the synchronized standard-library form. Both turn “maybe initialized” into a controlled state transition:

static REGION: OnceLock<String> = OnceLock::new();
let region = REGION.get_or_init(|| load_region());

Use them for values whose identity becomes stable after initialization. They are not general configuration reload mechanisms. A failed or panicking initializer has behavior defined by the chosen API and must not expose a half-initialized value. If initialization performs I/O, blocks startup, or can fail recoverably, make that operational contract visible rather than hiding it behind a getter.

Prefer explicit construction when dependency order can be expressed normally. Global lazy initialization complicates tests, process reuse, shutdown, and configuration isolation.

Start with the weakest runtime promise

When one clear owner exists, ordinary ownership and &mut still provide the best failure mode: a conflicting schedule is rejected before the program runs. Use Cell when local state can be copied or replaced without lending a view into it. Use RefCell when one thread needs shared access to a value and the program can make dynamic borrow conflicts impossible—or handle them explicitly.

Cross-thread invariants need synchronization, not merely interior mutability. Start with Mutex when one exclusive guard tells the truth. Move to RwLock only when measurements and the read/write schedule justify its coordination cost. Use OnceCell or OnceLock when initialization is genuinely a one-way transition, not as a disguised reload system.

Some state should not be shared at all. A bounded channel can transfer work to its owner and make queue capacity and disconnection explicit. An immutable snapshot can buy simple reads with copying or allocation. Atomics can serve independently meaningful transitions, but a group of atomic fields does not by itself preserve a compound invariant. Every alternative relocates cost and failure; none erases them.

Split a global lock only along real invariant boundaries

Suppose State contains immutable configuration, per-route counters, a work queue, and a shutdown flag. One global lock conflates four contracts.

A narrower design might use:

  • Arc<Config> for immutable shared configuration;
  • sharded Mutex<Counter> or suitable atomics for independent counters;
  • a bounded channel for work ownership and backpressure;
  • an explicit cancellation or shutdown mechanism;
  • one coordinator that owns transitions spanning domains.

This decomposition gives configuration reads no reason to wait behind counter updates and gives queued work an owner rather than a shared vector. It also creates an obligation: any operation spanning those domains needs a coordinator or an explicit order.

If configuration and queue admission must change atomically, splitting them merely manufactures a race. Conversely, retaining one global lock because it is easy can hide callbacks, slow I/O, and unrelated state behind the same guard. Write the invariant first. Put all—and only—the state required to preserve it behind one enforcement boundary.

API signaling and failure policy

A type with an &self method that may panic on dynamic borrow conflict should document that possibility or establish why conflict is impossible. Locking methods should document blocking, callback, poisoning, and lock-order behavior. Returning guards exposes lock duration to callers; accepting a closure can bound it but introduces reentrancy concerns. Copying data out shortens a guard at an explicit snapshot cost.

Public names can signal semantics: try_update, with_entries, snapshot, get_or_init, and lock communicate more than a surprising observational getter that mutates and blocks.

Failure modes

  • Wrapping the whole service in Arc<Mutex<_>> to solve an ownership or lifetime question.
  • Borrowing a RefCell mutably across a callback or formatting operation.
  • Treating RefCell as thread synchronization.
  • Treating RwLock as automatically superior for reads.
  • Holding a blocking lock across async suspension.
  • Splitting locks without preserving cross-field invariants or lock order.
  • Hiding fallible initialization, lock poisoning, or dynamic-borrow panic from callers.
  • Reaching for UnsafeCell directly without a safety case.

Senior review checklist

  • Can ordinary ownership or a consuming transition express the mutation?
  • Which exact invariant is protected by each cell or lock?
  • Is the mechanism local-only, synchronized, or one-time, and does the type say so?
  • What happens on conflicting dynamic borrow, poison, contention, or failed initialization?
  • Can callbacks, formatting, destructors, or async suspension occur while a guard is held?
  • Are lock scope and lock order observable and bounded?
  • Would a channel, owned command, or immutable snapshot simplify lifecycle?
  • Has RwLock, sharding, or atomic replacement been justified by evidence?

Architecture exercise: shrink the synchronization domain

Given an Arc<Mutex<ServiceState>> containing immutable configuration, counters, a pending-work vector, and shutdown state, produce an ownership and invariant map. Redesign it with at least two smaller domains and a bounded ownership-transfer path. Your decision record must identify operations that span domains, lock order or coordinator policy, backpressure, callback placement, poison recovery, shutdown, and tests for reentrancy and deadlock-sensitive ordering. Preserve a one-lock alternative and state when its simplicity should win.

Durable takeaways

  1. UnsafeCell permits interior mutation but supplies none of the safe checking or synchronization by itself.
  2. Cell avoids lending interior references; RefCell enforces shared/exclusive borrowing dynamically within one thread.
  3. Mutex and RwLock add synchronization plus contention, poisoning, and liveness contracts.
  4. One-time cells model initialization, not arbitrary reloadable global state.
  5. The correct mechanism protects the smallest complete invariant domain and makes runtime failure or blocking visible.

Choosing an enforcement boundary still leaves an owner responsible for its guards, locks, buffers, and external resources. The next design question is what cleanup ownership guarantees—and what reliable shutdown must never be hidden inside destruction.

Sources and version notes