Programming Languages · Memory and Ownership
Interior Mutability and the Runtime Borrow Flag
How a language that checks aliasing at compile time permits mutation through a shared reference, what the runtime flag costs, and how it fails at runtime instead of at compile time.
The aliasing rule that makes borrow checking tractable is blunt: at any point a value may have either one mutable reference or any number of shared references, never both. The rule is what lets the compiler assume that a &T does not change underneath a read and that a &mut T is the only path to the value. It is also wrong for a large class of real data structures (caches, reference-counted graphs, observer registries, memoized functions) where a shared handle must be able to update state. Interior mutability is the sanctioned escape, and the cost of the escape is that some checks move from compile time to runtime.
The rule being escaped
The reason for the exclusivity rule is optimization, not aesthetics. A shared reference promises that the referent will not change for the reference’s lifetime, so a loop reading &x may hoist the load out of the loop. A mutable reference promises that nothing else observes the referent, so writes need not be ordered against other reads. Both promises are unsound if a shared reference can reach mutable state.
C’s restrict is the same promise made by hand and unchecked: a pointer qualified restrict tells the compiler that no other pointer in the same scope accesses the same object, and violating it is undefined behaviour that produces wrong answers rather than a diagnostic. Rust’s version is checked and total: every reference is classified, and the classification is enforced.
Interior mutability is the mechanism by which a type opts out of the shared-reference promise for part of itself. &Cell<T> is a shared reference, but the Cell permits writing through it. That is legal because Cell never hands out a reference to its contents; it copies values in and out, so there is no moment at which a &T into the cell exists while a write happens.
Two designs: copy out, or count borrows
Cell<T> never hands out a reference at all: replace swaps a new value in and returns the old one, set drops the old value and stores the new one, and get is available when T is Copy. There is no borrow to track because no reference to the interior ever escapes the cell. It is !Sync, so it cannot be shared across threads; AtomicUsize and its siblings are the thread-safe analogue, with the same copy-in/copy-out interface and hardware ordering instead of a flag.
RefCell<T> handles types that cannot be copied, such as a Vec or a tree. It keeps a borrow counter and hands out guard objects:
use std::cell::RefCell;
let cell = RefCell::new(vec![1, 2, 3]);
{
let mut v = cell.borrow_mut(); // count: one mutable borrow
v.push(4);
} // guard dropped, count back to zero
let v = cell.borrow(); // shared borrow now permitted
assert_eq!(v.len(), 4);
The counter is a Cell<isize>. Zero means unborrowed, a positive value counts shared borrows, and any negative value means one mutable borrow. borrow increments and panics if the value is negative; borrow_mut sets -1 and panics if the value is nonzero. try_borrow and try_borrow_mut return a Result instead, and any code that cannot tolerate a panic must use them.
The negative encoding is not an implementation quirk. It makes the two failure tests cheap: a shared borrow tests count < 0, a mutable borrow tests count != 0. It also means a mutable borrow needs no separate state and the shared count never has to be split into a flag bit and a counter.
What the runtime check costs
A RefCell borrow is a load, a compare, and a store, plus the guard’s destructor on the way out. On a single thread that is a few cycles, comparable to the bounds check on a slice index. The real cost is not the instruction count but the invariants it forces on the surrounding code.
First, a Ref guard must not be held across a call that might re-enter the same cell. The classic failure is a method that borrows its own state mutably, then calls a helper that borrows it again; the second borrow panics with “already borrowed: BorrowMutError”. The compiler cannot help, because the two borrows are in different functions. Code that holds a Ref across an .await point has the same problem in a different form: the guard is not Send, so a future that holds one across a suspension point is itself not Send and cannot be spawned onto a runtime that may move tasks between threads. The compiler rejects the spawn rather than the borrow, which makes the mistake harder to read.
Second, the counter is per cell and not per thread, so RefCell is !Sync and cannot appear in shared state. The thread-safe version, RwLock, uses an atomic word and blocks rather than panics, with the same logical rule: many readers or one writer.
Third, the panic is the only static guarantee lost. A program using RefCell compiles with no aliasing proof, so a borrow error becomes a runtime failure that may only be reachable on an unusual input. That is a genuine regression from compile-time checking, and it is why the standard library’s guidance is to prefer restructuring the data so that RefCell is unnecessary: split the cell into separate fields, or use Cell for copyable state, or use a message-passing design.
Thread-safe interior mutability
Mutex<T> is the same trade with blocking. Its guard holds a lock; a second borrow from the same thread deadlocks rather than panics, which is worse to debug. RwLock<T> distinguishes readers from writers. Mutex<T> is Sync when T is Send, and RwLock<T> when T is both Send and Sync, since it can hand out shared guards that are held on several threads at once. That is what allows a shared Arc<Mutex<T>> to be the standard shared-mutable-state idiom across threads.
AtomicUsize and friends are the lock-free case: no guard, no borrow, and a single instruction with a memory ordering. The ordering parameter is where correctness lives. Relaxed permits reordering, Acquire on a load and Release on a store establish the happens-before edge that makes a published pointer safe to read, and SeqCst adds a total order across all such operations. Getting the ordering wrong is not a panic; it is a data race that a test on one machine will not show.
UnsafeCell<T> is the primitive underneath all of these. It is the only type for which the compiler permits mutation through a shared reference, and every other interior-mutability type is a wrapper that adds a policy (copy semantics, a counter, a lock, an atomic) around it. Writing a wrapper directly on UnsafeCell requires upholding the aliasing rule by construction, since the compiler’s guarantee stops at that boundary.
The compositional hazard
Interior mutability composes badly with reference counting. Rc<RefCell<T>> is the standard way to build a mutable graph, and it inherits both problems: the cycle that reference counting cannot break and the runtime borrow panic. A traversal that holds a Ref into one node while recursing into its children will panic as soon as a child points back to the parent and the traversal re-enters the same cell.
The usual resolution is to scope every borrow to a single step: copy out the fields needed, drop the guard, then recurse. That trades a clone for the panic, which is often the right trade for small fields and the wrong one for large ones. Rc<RefCell<Vec<T>>> plus an index-based traversal avoids the problem entirely, because the borrow is only held while indexing.
The same composition appears in C++ as std::shared_ptr plus a mutable member: aliasing is unchecked there, so a write through a shared handle while another thread reads the member is a data race rather than a panic. std::shared_ptr does not make the pointee safe to mutate; only the count is atomic. C#‘s lock and Java’s synchronized are the coarse version of the same trade: neither language has an aliasing rule, so mutability through a shared handle is the default and the check is entirely a convention about taking the right lock.
The design position
Interior mutability is not a workaround for a deficient type system. It is the explicit boundary between checked and unchecked aliasing, and its cost is proportionate to how much of the program crosses it. A type that uses Cell for a counter and a cache pays a few cycles per access and no runtime check. A type that uses RefCell throughout a graph traversal pays panics on unusual paths.
The reason the boundary is drawn at the type rather than at the reference is that the check has to be somewhere. Moving it to runtime means the compiler can still assume exclusivity for everything outside the boundary, so the optimization guarantees described in borrow checking as a dataflow analysis remain intact for the rest of the program. A language that gave up the rule globally, as C and Java do, would lose those guarantees everywhere in exchange for not needing the escape.
References
- The Rust Standard Library: Cell and RefCell — The Rust Project
- The Rustonomicon: Aliasing — The Rust Project
- ISO/IEC 9899:2018, Programming languages, C, 6.7.3 Type qualifiers — ISO/IEC