Skip to content
C Codeloom
Rust

Rust Smart Pointers: Box, Rc, and Arc

Understand when to reach for Box, Rc, and Arc in Rust, how each interacts with the borrow checker, and the cost of shared ownership across threads.

·4 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • What Box, Rc, and Arc each provide
  • When heap allocation is necessary
  • Reference counting versus single ownership
  • How Arc and atomics enable thread-safe sharing
  • Interior mutability with RefCell and Mutex

Prerequisites

  • Comfort with Rust ownership and borrowing

What and Why

Rust’s default is single ownership on the stack. Smart pointers extend that with heap allocation and shared ownership while still respecting the borrow checker. Box<T> is the simplest: it owns a heap value. Rc<T> adds non-atomic reference counting for single-threaded sharing. Arc<T> is the thread-safe sibling using atomic counts.

Picking correctly matters: Box is free at runtime beyond the allocation, Rc is cheap but single-threaded, and Arc is the only one safe to share across threads but incurs atomic operations.

Mental Model

Box<T>   : unique owner of heap value, no extra runtime cost
Rc<T>    : multiple owners, single thread, non-atomic count
Arc<T>   : multiple owners, multi thread, atomic count

For mutation through shared pointers:
Rc<RefCell<T>>   : single-threaded interior mutability
Arc<Mutex<T>>    : multi-threaded interior mutability
Arc<RwLock<T>>   : multi-threaded, many readers or one writer
Smart pointer cheat sheet

The borrow checker still applies. Wrapping in Rc or Arc does not give you mutable aliasing for free; you need an interior mutability primitive (RefCell, Mutex, RwLock).

Hands-on Example

use std::rc::Rc;
use std::sync::Arc;
use std::sync::Mutex;
use std::thread;

// Recursive type: Box gives it a known size
enum List {
    Cons(i32, Box<List>),
    Nil,
}

fn main() {
    let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
    let _ = list;

    // Rc: many owners, single thread
    let shared = Rc::new(vec![1, 2, 3]);
    let a = Rc::clone(&shared);
    let b = Rc::clone(&shared);
    println!("count = {}", Rc::strong_count(&shared)); // 3
    drop(a); drop(b);

    // Arc + Mutex: shared mutable state across threads
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];
    for _ in 0..4 {
        let c = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            let mut n = c.lock().unwrap();
            *n += 1;
        }));
    }
    for h in handles { h.join().unwrap(); }
    println!("counter = {}", *counter.lock().unwrap()); // 4
}

Three things to notice. Box is needed for the recursive enum because the compiler must know its size. Rc::clone increments a counter rather than copying the contents. Arc<Mutex<T>> is the canonical pattern for shared mutable state across threads.

Common Pitfalls

  • Reaching for Rc/Arc too soon: many problems do not actually need shared ownership. A reference (&T) or a clone is often clearer.
  • Reference cycles: Rc and Arc are reference counted. Cycles leak memory because the count never reaches zero. Break cycles with Weak<T>.
  • Rc across threads: the compiler will refuse, since Rc is not Send. Reach for Arc only when threads are actually involved; the atomics are not free.
  • Locking while holding another lock: Mutex deadlocks are easy to write. Define a consistent lock order or use try_lock with a timeout.
  • RefCell panics at runtime: borrow rules are still checked, just at runtime. A panic from borrow_mut while another borrow is alive is a logic bug; restructure to avoid it.

Practical Tips

Start with no smart pointer at all. If you need heap allocation, reach for Box. If you need shared ownership in one thread, Rc. If across threads, Arc. If you need shared mutability, add RefCell or Mutex accordingly.

For trait objects, Box<dyn Trait> is the standard way to store heterogeneous values. Arc<dyn Trait + Send + Sync> is the multithreaded version.

When designing public APIs, prefer to take &T or &mut T so callers can choose their ownership story. Forcing Arc<T> on callers is sometimes necessary but couples your API to a specific threading model.

For caches and graphs that need cycles, the pattern is Rc<RefCell<T>> with Weak<RefCell<T>> for back-edges. Upgrading a Weak returns Option<Rc<T>>, which is exactly the “the parent might be gone” semantic you usually want.

Profile before optimizing away atomics. Arc overhead is rarely the bottleneck; lock contention is.

Wrap-up

Box, Rc, and Arc are not interchangeable. Each encodes a specific ownership story that the borrow checker can verify at compile time. Use Box for unique heap allocation, Rc for single-threaded sharing, and Arc for cross-thread sharing. Combine with RefCell or Mutex when you need shared mutability. Once you can sketch the ownership graph of your data, picking the right pointer becomes mechanical.