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 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 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 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 watch are Arc clones on contended cache lines, RefCell guards held across re-entrant calls, and Rc cycles silently holding gigabytes.

References

Found this useful?

Published Updated