Unsafecell

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:

1
2
3
4
fn cheat(r: &u32) {
    let p = r as *const u32 as *mut u32;
    unsafe { *p = 99; } // UB: mutating through &T without UnsafeCell
}

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:

1
2
3
4
5
6
use std::cell::UnsafeCell;

let cell = UnsafeCell::new(10u32);
let p: *mut u32 = cell.get();    // legal through &cell
unsafe { *p = 20; }              // legal — UnsafeCell opted out of the no-mutation rule
assert_eq!(unsafe { *cell.get() }, 20);

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use std::cell::UnsafeCell;

struct MyCell<T> {
    value: UnsafeCell<T>,
}

impl<T: Copy> MyCell<T> {
    fn new(v: T) -> Self {
        Self { value: UnsafeCell::new(v) }
    }

    fn get(&self) -> T {
        // SAFETY: UnsafeCell makes Self !Sync, so no thread races us.
        // We never hand out a reference into the cell, so no aliased &mut exists.
        unsafe { *self.value.get() }
    }

    fn set(&self, v: T) {
        // SAFETY: same as `get`.
        unsafe { *self.value.get() = v; }
    }
}

let counter = MyCell::new(0u32);
counter.set(counter.get() + 1);
counter.set(counter.get() + 1);
assert_eq!(counter.get(), 2);

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:

1
unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {}

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.