Internals · 08 / 11
Internals

Box, Rc, Arc, RefCell, Cell

Five types, two questions. Who owns this value? — one owner (Box), counted owners on one thread (Rc), counted owners across threads (Arc). Who may mutate it? — the compile-time rules, or a runtime scheme (Cell, RefCell) built on the one primitive the compiler actually understands, UnsafeCell. This page opens each one up: the heap headers, the refcount arithmetic, the borrow flag, and a decision table that survives contact with real code.

Long read · heap layout and refcount mechanics through interior mutability · references at the end


1 · Box — ownership with an address

Box<T> is a heap allocation with single ownership: one word holding a pointer, no header, no count, no overhead beyond the allocator call. Drop the box, the value drops, the memory frees. Moving a box copies the pointer — the heap value never moves, which is why boxing is how you give a value a stable address.

rust src/main.rs · the three real jobs of Box
// 1. Recursive types — without indirection, List would be infinitely sized.
enum List {
    Cons(i32, Box<List>),
    Nil,
}

// 2. Unsizing — putting a dynamically sized value behind a thin handle.
let handler: Box<dyn Fn(&str) -> bool> = Box::new(|s| s.is_empty());
let bytes: Box<[u8]> = vec![1, 2, 3].into_boxed_slice();

// 3. Moving big values cheaply / off the stack.
let huge: Box<[u8; 1 << 20]> = vec![0u8; 1 << 20].try_into().unwrap();
fn take(h: Box<[u8; 1 << 20]>) {}   // moves 8 bytes, not 1 MiB

Box is also slightly magic: it's the one pointer type the compiler lets you move out of (let v = *boxed;), and dropck knows it strictly owns its contents. What Box is not for: "I think heap is needed here." A local String or Vec already heap-allocates its contents; wrapping values in Box by reflex is C++ habit, not Rust.

2 · Rc — shared ownership, one thread

Rc<T> answers "this value has no single natural owner" — a node referenced by several parents, a config shared by many subsystems. The allocation holds a header next to the value:

rust alloc/src/rc.rs · the heap block (shape)
struct RcInner<T: ?Sized> {
    strong: Cell<usize>,   // owners: clones of Rc<T>
    weak:   Cell<usize>,   // observers: Weak<T> (+1 shared by all strongs)
    value:  T,
}
// Rc::clone(&x)  -> strong += 1; copy the pointer.   No deep copy, ever.
// drop(Rc)       -> strong -= 1;
//                   strong == 0  => drop value (destructor runs now)
//                   weak   == 0  => free the allocation itself

Two counts because destruction happens in two stages: when strong hits zero the value is dropped, but the heap block must outlive it as long as any Weak might still call upgrade() — upgrade checks strong > 0 and returns None after death. The counts are plain Cell<usize>, which makes clone/drop a non-atomic increment — fast, and the exact reason Rc is !Send (the Send + Sync page walks through that).

rust src/main.rs · cycles leak; Weak breaks them
use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
    parent: RefCell<Weak<Node>>,     // up-pointer: Weak, or the tree leaks
    children: RefCell<Vec<Rc<Node>>>, // down-pointers: Rc, owning
}

fn main() {
    let root = Rc::new(Node { parent: RefCell::new(Weak::new()), children: RefCell::new(vec![]) });
    let leaf = Rc::new(Node { parent: RefCell::new(Rc::downgrade(&root)), children: RefCell::new(vec![]) });
    root.children.borrow_mut().push(leaf.clone());

    println!("root strong={} weak={}", Rc::strong_count(&root), Rc::weak_count(&root));
    println!("leaf parent alive: {}", leaf.parent.borrow().upgrade().is_some());
}

Make both directions Rc and the pair keeps each other's strong count at 1 forever: nothing is dropped, nothing is freed, and no error is raised — leaks are safe in Rust. The rule of thumb: ownership flows one way as Rc; back-references and caches hold Weak and re-check with upgrade().

3 · Arc — the same idea, paid for in atomics

Arc<T> is structurally identical with AtomicUsize counts. The memory-ordering details are a small masterclass in "as weak as possible, as strong as necessary":

rust alloc/src/sync.rs · the two interesting lines
// clone: Relaxed is enough. We already own one reference, so the count
// is >= 1 and nobody can free the data out from under us mid-increment.
let old_size = self.inner().strong.fetch_add(1, Relaxed);

// drop: Release on the decrement, then an Acquire fence before freeing.
// Release publishes all our writes to the value; the Acquire on the
// thread that hits zero makes every other thread's writes visible
// before the destructor reads the value.
if self.inner().strong.fetch_sub(1, Release) != 1 { return; }
atomic::fence(Acquire);
unsafe { self.drop_slow() }   // drop value, then handle the weak count

Costs, honestly: an uncontended atomic increment is a few cycles; a contended one — many threads cloning the same Arc in a hot loop — serialises on the cache line holding the count and shows up clearly in profiles. The standard mitigation is to clone once per thread/task at the edge and pass &T downward. Note also that Arc gives you shared ownership, not shared mutability: Arc<T> alone only ever hands out &T. Mutation needs a lock inside (Arc<Mutex<T>>, Arc<RwLock<T>>) or atomics in T.

Both Rc and Arc support clone-on-write at the edges: Rc::get_mut gives &mut T only if you're the sole owner, and Rc::make_mut clones the value first if you're not — a cheap persistent-data-structure trick that im-style crates build on.

4 · UnsafeCell — the only true door

Everything so far obeys the compile-time rule: shared XOR mutable. Interior mutability is the controlled exception, and there is exactly one primitive for it. UnsafeCell<T> is a repr(transparent) wrapper whose single power is that the compiler stops assuming data behind &UnsafeCell<T> is immutable — it is the one legal way to mutate through a shared reference. Mutating through &T by any other route (transmute, raw-pointer casts) is undefined behaviour, full stop.

rust core/src/cell.rs · the entire primitive
#[repr(transparent)]
pub struct UnsafeCell<T: ?Sized> {
    value: T,
}
impl<T: ?Sized> UnsafeCell<T> {
    pub const fn get(&self) -> *mut T { ... }   // &self -> *mut T. That's it.
}
// Cell, RefCell, Mutex, RwLock, OnceCell, the atomics — every interior-
// mutability type in the language is UnsafeCell plus a discipline for
// proving the accesses don't alias. They differ only in the discipline.

5 · Cell — copy values through the wall

Cell<T>'s discipline: never hand out a reference to the inside. You get() a copy out, set() a value in, replace() or take() to swap. Since no reference into the contents ever exists, no aliasing is possible, and every operation is a plain load or store — zero runtime cost.

rust src/main.rs · Cell for counters and flags
use std::cell::Cell;

struct Parser {
    pos: Cell<usize>,     // mutate position through &self
    errors: Cell<u32>,
}

impl Parser {
    fn bump(&self) {                       // note: &self, not &mut self
        self.pos.set(self.pos.get() + 1);
    }
}

fn main() {
    let p = Parser { pos: Cell::new(0), errors: Cell::new(0) };
    p.bump(); p.bump();
    println!("pos = {}", p.pos.get());
}

The fit is small Copy data — counters, flags, cached indices — in types whose methods logically take &self. For non-Copy types Cell still works via replace/take but gets awkward; that's RefCell's territory. Cell is Send (if T is) but !Sync: the no-references trick only protects you when one thread is doing the loads and stores.

6 · RefCell — the borrow checker at runtime

RefCell<T>'s discipline is literally the borrow checker's rule, enforced by a counter at runtime: a Cell<isize>-style flag counts outstanding shared borrows, or marks one exclusive borrow. borrow() increments it and returns a Ref<T> guard; borrow_mut() demands it be zero and returns RefMut<T>; the guards' destructors decrement. Break the rule and you don't get UB — you get a panic (BorrowMutError), at the exact line of the second borrow.

rust src/main.rs · the panic you'll eventually meet
use std::cell::RefCell;

fn main() {
    let cache = RefCell::new(vec![1, 2, 3]);

    let items = cache.borrow();           // shared borrow live...
    cache.borrow_mut().push(4);           // panic: already borrowed
    println!("{}", items.len());
}
// thread 'main' panicked at 'already borrowed: BorrowMutError'
//
// The non-panicking forms return Result instead:
//   cache.try_borrow_mut()  -> Err(BorrowMutError) here
//
// The classic real-world shape: borrow() held across a call that
// re-enters the same RefCell (callbacks, visitors, event handlers).

That trade — compile-time proof exchanged for a runtime check and a panic path — is the whole product. Use it where the aliasing is genuinely dynamic (graph algorithms, memo caches, observer lists) and keep the guards short-lived: take the borrow, do the work, let it drop before calling anything that might borrow again. RefCell is !Sync like Cell; its multi-threaded sibling is Mutex/RwLock, where "panic on conflict" becomes "block on conflict".

7 · Composing them — and the decision table

The pointers compose along the two axes independently. Counted ownership plus runtime mutability gives the two workhorse stacks: Rc<RefCell<T>> for single-threaded shared mutable state, Arc<Mutex<T>> (or RwLock) across threads. The mirror is exact: Rc→Arc swaps non-atomic for atomic counts, RefCell→Mutex swaps panic-on-conflict for block-on-conflict.

You needReach forCost
Heap allocation, one owner (recursion, dyn Trait, big moves)Box<T>one allocation
Several owners, one threadRc<T>+16-byte header, non-atomic count bumps
Several owners, many threadsArc<T>atomic count bumps; contention on hot clones
Mutate small Copy data through &selfCell<T>free
Mutate anything through &self, one threadRefCell<T>flag check; panic path
Shared mutable, one threadRc<RefCell<T>>both of the above
Shared mutable, many threadsArc<Mutex<T>>atomics + lock
Back-pointer / cache that mustn't ownWeak<T>upgrade check per access
The order to try things. Plain ownership and &/&mut borrows first; restructuring (split structs, pass the field not the struct, return values instead of mutating) second; smart pointers third. Rc<RefCell<T>> everywhere is the classic signature of a design fighting the borrow checker rather than listening to it — every .borrow_mut() is an assertion "no other borrow is live right now" that nobody is checking until production panics. It's the right tool for genuinely shared mutable graphs; it's the wrong default.

8 · Sizes and costs, measured

rust src/main.rs · what each handle weighs
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
use std::mem::size_of;

fn main() {
    println!("Box<u64>:      {}", size_of::<Box<u64>>());      // thin pointer
    println!("Rc<u64>:       {}", size_of::<Rc<u64>>());       // thin pointer (header on heap)
    println!("Arc<u64>:      {}", size_of::<Arc<u64>>());
    println!("Box<dyn Fn()>: {}", size_of::<Box<dyn Fn()>>()); // fat: ptr + vtable
    println!("Cell<u64>:     {}", size_of::<Cell<u64>>());     // zero overhead
    println!("RefCell<u64>:  {}", size_of::<RefCell<u64>>());  // + borrow flag word
}

The handles are all pointer-sized; Rc/Arc pay their two-count header on the heap, RefCell pays one word inline for the borrow flag, Cell pays nothing. None of them is "slow" — the costs to actually watch are Arc clones on contended cache lines, RefCell guards held across re-entrant calls, and Rc cycles quietly holding gigabytes.

References

Found this useful?