154. UnsafeCell<T> — The Primitive Every Interior-Mutability Type Is Built On
This morning’s OnceCell / LazyCell bite — and Cell, RefCell, Mutex, RwLock, OnceLock, every atomic — all bottom out at the same type: UnsafeCell<T>. It is the one and only legal way in Rust to mutate through a shared reference. You’ll almost never type its name, but knowing what it does explains why all the rest exist.
The pain: &T is “you may not mutate” — to the optimizer too
Outside UnsafeCell, the compiler is allowed to assume &T points to data that won’t change underneath it. It can cache loads, hoist reads out of loops, mark pointers noalias in LLVM. Try to fake interior mutability with a raw cast and you’ve signed up for undefined behavior — not “it works but is ugly,” actual UB the optimizer is free to exploit:
| |
The cast compiles. The write may even appear to happen. But the program has lost all guarantees, and the next release of rustc — or a different opt level — can break it.
The fix: UnsafeCell::get returns *mut T through &self
UnsafeCell<T> is the one type the compiler treats specially: a &UnsafeCell<T> is not a promise that the inside won’t change, so noalias and friends don’t apply. Its .get() method takes &self and hands back a raw *mut T:
| |
That’s the whole feature. Every other interior-mutability type in std is a safe wrapper around exactly this primitive plus an invariant — a borrow counter, a lock bit, an atomic flag — that justifies the unsafe block inside.
Build Cell from scratch in 20 lines
To see it in action, here’s a stripped-down Cell<T> clone. Real std::cell::Cell is more polished, but the shape is identical:
| |
UnsafeCell does the optimizer-level work; the Copy bound plus “never lend a reference out” does the safety work. Drop either piece and the type is unsound.
The !Sync default — and how Mutex opts back in
UnsafeCell<T> is !Sync even when T: Sync. That’s deliberate: if you could share an &UnsafeCell<T> across threads with no synchronization, two threads could race on the inside and you’d be back to UB.
That’s why Cell and RefCell are !Sync (single-thread only), and why Mutex<T> carries an explicit:
| |
The unsafe impl is the author asserting “I added the lock; now sharing across threads is sound.” The pattern recurs in every sync interior-mutability type — RwLock (tomorrow’s afternoon bite), OnceLock, LazyLock, the atomics. The shape is always: UnsafeCell<T> + an invariant + an unsafe impl Sync.
When to reach for it yourself
In application code: basically never. Cell, RefCell, Mutex, RwLock, OnceCell, OnceLock cover every common pattern and they’re already audited.
Real reasons to hold UnsafeCell directly: writing a new lock primitive, an arena that hands out &mut slots from &self, a lock-free data structure, FFI cells that mirror an existing C struct. If you’re not building infrastructure of that kind, the right move is to use a wrapper that already wraps it — and now you know what’s in the bottom of the box.