Systems Programming · Unsafe Code

Aliasing Rules for References and the Role of UnsafeCell

A shared reference is a promise of immutability that the optimiser acts on, and UnsafeCell is the single opt-out that makes interior mutability representable without lying to the compiler.

The borrow checker enforces aliasing rules that are stricter than any C compiler’s, and the reason is not that Rust’s authors dislike aliasing. It is that a shared reference &T is a language-level promise: for the duration of the borrow, the referent does not change. That promise is worth more to an optimiser than the type information it replaces. A load through &T can be hoisted, commoned, and kept in a register across arbitrary code, because nothing is permitted to write through any other path.

The promise is false in some programs, and the language needs a way to say so without abandoning the promise everywhere. That way is UnsafeCell<T>, the one type for which the immutability guarantee does not hold. Understanding why exactly one such type exists, and what the aliasing model does with references in the presence of raw pointers, is the difference between writing interior mutability that works and writing interior mutability that breaks under a new compiler release.

The two promises, and the third

&T promises shared immutability: many readers, no writers, no mutation for the borrow’s lifetime. &mut T promises unique access: exactly one usable path to the referent, and no other reference, shared or mutable, alive at the same time. The second promise is what licenses the compiler to move a value out through the reference, to treat the referent as uninitialised afterwards, and to assume that a write through it is not observed by anything else.

There is a third property the type system does not encode and the optimiser still assumes: that a reference is dereferenceable for its whole lifetime. A &T must point at a live, aligned, initialised T at every instant between its creation and its last use, even if the program never reads through it. This is stronger than C’s rule for pointers, which permits a pointer to be dangling as long as it is not dereferenced. In Rust, creating a reference to a dead value is already undefined behaviour.

Consequences follow directly. &Vec<T> and &mut Vec<T> may not coexist with a raw pointer into the vector’s buffer in a way that lets the vector reallocate, because reallocation frees the buffer and the reference into it becomes dangling even if nothing reads it. Code that holds an index and a reference and calls a method that reallocates is not merely racy; it is undefined.

Where UnsafeCell fits

UnsafeCell<T> is a struct with a single field, and its only special property is that it opts its contents out of the immutability guarantee: a &UnsafeCell<T> does not assert that the T inside is unchanged. Every interior-mutability type in the standard library is built on it. Cell<T>, RefCell<T>, Mutex<T>, RwLock<T>, and the atomics all wrap their payload in an UnsafeCell and add the discipline that makes mutation through a shared reference safe.

The rule is precise: if a type contains an UnsafeCell anywhere in its layout, including behind a & or a Box, then &T does not imply immutability of that part. The compiler determines this at type-definition time, not at each use, so a struct with a private UnsafeCell field behaves as a UnsafeCell for aliasing purposes even though callers cannot name the field.

use std::cell::UnsafeCell;

struct Counter {
    value: UnsafeCell<u64>,
}

impl Counter {
    fn increment(&self) -> u64 {
        // Sound because the caller's &Counter cannot be shared across threads:
        // Counter is not Sync, so no two threads hold a &Counter at once.
        unsafe { *self.value.get() += 1 }
        unsafe { *self.value.get() }
    }
}

The comment states the invariant the compiler cannot check. Counter is not Sync because UnsafeCell is not Sync, so the shared reference cannot cross a thread boundary, so the read-modify-write cannot race. That single auto-trait derivation is what makes the unsafe block sound, and it is exactly the kind of reasoning that a manual unsafe impl Sync would discard.

What the optimiser does with the promise

The visible consequence of &T immutability is that a loop reading through a shared reference can load once and reuse the value. If the referent changes underneath, the loop observes the stale value. This is not a theoretical concern; it is why Cell exists and why the standard library does not expose a &mut alias of a Cell’s interior.

The visible consequence of &mut T uniqueness is that the compiler may assume a write through the reference is not visible through any other path, may sink or hoist it, and may keep the referent in a register. Two &mut to the same object, formed by any means including raw pointers, violate this and produce miscompilation rather than a panic.

Neither promise is enforced at runtime. The borrow checker enforces them statically for references it can see, and raw pointers are where it stops. The aliasing model that governs raw pointers is therefore the missing half of the specification, and it is what makes the difference between “the code compiled” and “the code is defined”.

The model behind raw pointers

Stacked Borrows, the model proposed by Ralf Jung and colleagues, gives references and raw pointers a shared semantics. Each memory location carries a stack of borrow tags. Creating a reference pushes a tag; using a raw pointer derived from that reference requires the tag to still be present; writing through a &mut pops everything above it, invalidating any raw pointer derived earlier. A use of an invalidated tag is undefined behaviour.

The practical content is a rule programmers can apply without the formalism. A raw pointer derived from a reference is valid only while that reference is not used again for a conflicting access. A &mut that is used invalidates raw pointers derived from it before that use. A & that is used does not invalidate derived raw pointers, but it invalidates any &mut derived from the same location.

Tree Borrows is a later model with a more permissive treatment of some patterns, chiefly those where a shared reference and a raw pointer derived from it are interleaved with writes through the pointer. Both models reject the core violations; they differ on the marginal cases that real code hits.

The models matter because they predict what an optimiser is allowed to do. A program that violates Stacked Borrows has no defined behaviour, so a compiler change that exposes the violation is not a regression. A program that satisfies the model is protected against such changes, insofar as the model is the specification the compiler targets.

Applying the rule to real structures

The recurring shape is a structure that holds both a collection and a raw pointer into the collection’s buffer. The pointer is valid until the collection reallocates, and every method that can reallocate must invalidate it. Storing the pointer and the length in the same structure, and refreshing both after any mutation, is the discipline that makes the pattern sound. Holding the pointer across a call that might reallocate is the violation, and it is invisible to the borrow checker because the pointer carries no lifetime.

The second recurring shape is the self-referential structure, where a field points at another field of the same object. Moving the object invalidates the pointer, which is why such structures are usually pinned: the Pin guarantee is exactly that the value will not move again, and it is what makes the internal pointer stable. Without pinning, the pointer is dangling after the first move, and creating the reference through it is undefined even if the bytes are still where they were.

The third is the callback that receives a &mut and stores a raw pointer derived from it for later use. The pointer is valid only while the &mut is not used again. A callback that stores the pointer and then lets the caller continue using the reference has created a pointer that a later write will invalidate.

Each of these is a case where the type system has no representation of the invariant, and the invariant is the reason the code works. The sound-wrapper discipline that turns such invariants into safe APIs is treated in What Makes a Safe Wrapper Around Unsafe Code Sound; the memory-initialisation side of the same problem appears in Uninitialised Memory, Padding, and the Cost of Assuming Zero.

References

  1. The Rustonomicon: Data Layout and Aliasing — Rust project
  2. Stacked Borrows: An Aliasing Model for Rust — Jung et al., POPL 2020
  3. The Rust Reference: UnsafeCell — Rust project
  4. std::cell module documentation — Rust project