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.
// 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 MiBBox 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:
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 itselfTwo 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).
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":
// 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 countCosts, 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.
#[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.
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.
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 need | Reach for | Cost |
|---|---|---|
| Heap allocation, one owner (recursion, dyn Trait, big moves) | Box<T> | one allocation |
| Several owners, one thread | Rc<T> | +16-byte header, non-atomic count bumps |
| Several owners, many threads | Arc<T> | atomic count bumps; contention on hot clones |
Mutate small Copy data through &self | Cell<T> | free |
Mutate anything through &self, one thread | RefCell<T> | flag check; panic path |
| Shared mutable, one thread | Rc<RefCell<T>> | both of the above |
| Shared mutable, many threads | Arc<Mutex<T>> | atomics + lock |
| Back-pointer / cache that mustn't own | Weak<T> | upgrade check per access |
&/&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
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
- The Book, ch. 15: Smart Pointers — the ground-floor walkthrough.
- std::cell module docs — the best single page on interior mutability and when each cell fits.
- std::rc module docs — counts, cycles, Weak semantics.
- alloc/sync.rs source — Arc's orderings, with the comments quoted above.
- Rustonomicon — for building your own; the Arc chapter reconstructs it from raw parts.