rust-syntax-smart-pointers
Owning pointers and shared-ownership primitives: Box<T>, Rc<T>, Arc<T>, RefCell<T>, Cell<T>, OnceCell<T>, OnceLock<T>, LazyLock<T>, Weak<T>, and a Pin<T> overview. Each picks a different combination of: heap allocation, single-vs-multi-thread refcounting, interior mutability, and lazy initialization. Choosing wrongly leaks, deadlocks, panics, or fails to compile.
Cross-references: [[rust-core-memory-model]] (stack vs heap, UnsafeCell), [[rust-syntax-borrowing]] (the borrow rules RefCell enforces at runtime), [[rust-syntax-async-await]] (Pin<&mut Self> in Future::poll; deep pinning reference under that skill), [[rust-impl-concurrency]] (Arc<Mutex<T>> vs atomics in real workloads).
When to use this skill
- User reaches for
Box<T>for a recursive type (tree, linked list) or adyn Traitreturn. - User wants to share read-only data across a single thread (
Rc<T>) or across threads (Arc<T>). - User asks "how do I mutate through a shared reference" and needs to choose
Cell/RefCell/Mutex/RwLock/Atomic*. - User wants a lazily-initialized global and is tempted to use
static mutorlazy_static!. - User builds a graph / observer / parent-child tree and hits a reference cycle (memory leak with
Rc<RefCell<T>>). - User sees
Future::poll(self: Pin<&mut Self>, ...)and asks "what isPin".
The decision in one table
| You need... | Pick | Why |
|---|---|---|
| Heap allocation of a single owned value | Box<T> |
Cheapest owned heap pointer. |
Recursive type (Node { next: ??? }) |
Box<Node> |
Breaks the infinite size. |
| Owned trait object | Box<dyn Trait> |
Heap-stored vtable pointer. |
| Shared ownership, single thread | Rc<T> |
Non-atomic refcount, no Send/Sync cost. |
| Shared ownership, multi thread | Arc<T> |
Atomic refcount. T must be Send + Sync for Arc<T>: Send + Sync. |
| Shared + Copy interior | Cell<T> (single thread) / Atomic* (multi thread) |
Zero or near-zero cost. |
| Shared + runtime-checked borrow | RefCell<T> (single thread) / Mutex<T>/RwLock<T> (multi thread) |
Panic / block on violation. |
| Init-once value | OnceCell<T> (single thread) / OnceLock<T> (multi thread, 1.70+) |
No lock after init. |
| Lazy global (init on first access) | LazyLock<T> (1.80+) static; LazyCell<T> (1.80+) thread-local |
Replaces lazy_static!. |
| Cycle-breaking child pointer | Weak<T> (from Rc::downgrade / Arc::downgrade) |
Does not count toward strong refs. |
| Self-referential struct (async future state) | Pin<P> |
See Pin overview below and [[rust-syntax-async-await]]. |
ALWAYS pick the lowest-overhead primitive that satisfies the requirement. NEVER reach for Arc<Mutex<T>> as a first instinct: in many cases AtomicUsize, RwLock, or Arc<T> alone is correct.
Box<T>: owned heap allocation
Box<T> is the simplest smart pointer: it owns a single T on the heap. Drops the value when the Box drops.
Source: https://doc.rust-lang.org/std/boxed/index.html
fn box_basic() {
let b: Box<i32> = Box::new(5);
println!("{}", *b); // Deref to i32
}
When Box is the right tool
Recursive types. The compiler cannot size
enum List { Cons(i32, List), Nil };Boxadds a fixed-size indirection.enum List { Cons(i32, Box<List>), Nil, }Trait objects.
Box<dyn Error>,Box<dyn Fn() + Send>give owned, dyn-dispatched values.Large stack values you want to move cheaply. Moving a
Box<Huge>copies one pointer; moving aHugeby value copies all bytes.Box::leakfor'staticreferences. Promotes the heap allocation to live for the program's lifetime, returning&'static mut T. Used for one-time-initialized configuration that the rest of the program borrows.let cfg: &'static str = Box::leak(Box::new(String::from("hi"))).as_str();NEVER call
Box::leakin a hot path or per-request. The memory is genuinely leaked.
NEVER Box<T> a small Copy type that fits in a register (Box<u32>, Box<bool>). The heap round-trip is pure cost with no benefit.
Rc<T>: single-thread shared ownership
Rc<T> ("reference counted") gives shared, immutable access to a T on the heap. Cloning increments a non-atomic counter. The last Rc drops the value.
Source: https://doc.rust-lang.org/std/rc/index.html
use std::rc::Rc;
fn rc_basic() {
let a = Rc::new(String::from("hi"));
let b = Rc::clone(&a); // refcount = 2
println!("{} {} count={}", a, b, Rc::strong_count(&a));
}
Rc<T>: !SendandRc<T>: !Sync. Compiler error on cross-thread move.Rc::clone(&a)is preferred overa.clone()to make the intent explicit; both do the same thing.Rc<T>is read-only. Combine withRefCell<T>for shared+mutable single-thread:Rc<RefCell<T>>.
ALWAYS use Rc::clone(&a), not a.clone(), to communicate "I am bumping a refcount, not deep-copying".
NEVER hold an Rc<T> in a struct that will be sent to another thread. Switch to Arc<T> from the start; retrofitting is invasive.
Weak<T>: break cycles
Rc<RefCell<Node>> graphs can leak when two nodes point at each other (parent <-> child). Weak<T> is a non-owning pointer: it does not contribute to the strong count and must be upgrade()d to access the value (returns Option<Rc<T>> because the value may have been dropped).
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
parent: RefCell<Weak<Node>>, // parent: non-owning
children: RefCell<Vec<Rc<Node>>>, // children: owning
}
ALWAYS make the back-edge (parent, observer-of) Weak. The forward-edge (child, owner) stays Rc/Arc. This is the canonical fix for cycles.
NEVER make BOTH directions Rc/Arc. Drop order will never run; memory leaks silently.
Arc<T>: multi-thread shared ownership
Arc<T> ("atomically reference counted") is the multi-thread analogue of Rc<T>. Cloning uses atomic increment.
Source: https://doc.rust-lang.org/std/sync/struct.Arc.html
use std::sync::Arc;
use std::thread;
fn arc_basic() {
let data = Arc::new(vec![1, 2, 3]);
let h: Vec<_> = (0..3).map(|_| {
let d = Arc::clone(&data);
thread::spawn(move || println!("{:?}", d))
}).collect();
for j in h { j.join().unwrap(); }
}
Arc<T>: Send + SynciffT: Send + Sync. The compiler enforces this.- Atomic operations are more expensive than
Rc's non-atomic ones, but still cheap (typically a single CAS). - For mutation across threads, wrap in
Mutex,RwLock, or an atomic type:Arc<Mutex<T>>,Arc<RwLock<T>>,Arc<AtomicUsize>.
ALWAYS reach for Arc<T> only when you actually need cross-thread shared ownership. If only one thread reads, Rc<T> is faster.
NEVER use Arc<Mutex<T>> when an atomic suffices. For counters, flags, and primitive ints/bools, Arc<AtomicUsize> (or even a plain &'static AtomicUsize) is faster and harder to misuse.
Arc<Mutex<T>> vs Arc<RwLock<T>> vs atomics
Mutates a single integer/bool/pointer?
|
+-- Yes: AtomicX (AtomicUsize, AtomicBool, etc.). No Arc<Mutex> needed for primitives.
|
+-- No: many readers, occasional writer?
|
+-- Yes: Arc<RwLock<T>>. Readers parallel, writers exclusive.
|
+-- No (mixed read/write, contention low): Arc<Mutex<T>>. Simplest.
ALWAYS measure before assuming Arc<RwLock<T>> is faster than Arc<Mutex<T>>. RwLock is more complex and not always a win under contention.
NEVER hold a std::sync::Mutex guard across .await. The runtime can move the task to another thread. Use tokio::sync::Mutex in async code. See [[rust-impl-concurrency]] and [[rust-syntax-async-await]].
Cell<T> and RefCell<T>: interior mutability (single thread)
The borrow rules ([[rust-syntax-borrowing]]) say: many &T OR one &mut T, never both. Sometimes you genuinely need to mutate through &T. Cell and RefCell are the sound escape hatches, both built on UnsafeCell<T>.
Cell<T>: zero-cost, get/set only
use std::cell::Cell;
let c = Cell::new(0_i32);
c.set(c.get() + 1); // T: Copy fast path
Cell<T>never hands out&Tor&mut Tto the contents. Onlyget()(returnsTby copy) andset(T)(replace).- For
T: !Copy, usereplace(new),take(),swap(other). !Sync(single thread only).- Zero runtime overhead.
ALWAYS prefer Cell<T> over RefCell<T> when T: Copy or you only need swap semantics.
NEVER try to read &T out of a Cell. The API does not allow it by design.
RefCell<T>: runtime-checked & / &mut
use std::cell::RefCell;
let r = RefCell::new(vec![1, 2, 3]);
{
let v = r.borrow();
println!("{}", v.len());
} // borrow drops
{
let mut v = r.borrow_mut();
v.push(4);
}
borrow()returnsRef<'_, T>,borrow_mut()returnsRefMut<'_, T>. Both are smart pointers that release the borrow on drop.- Violating the borrow rules at runtime panics: "already borrowed" / "already mutably borrowed".
RefCell<T>: !Sync.- Use
try_borrow()/try_borrow_mut()to handle the failure without panic in re-entrant code.
ALWAYS limit the scope of Ref / RefMut to the smallest possible block. The borrow lives until the guard drops, exactly like MutexGuard.
NEVER hold a RefCell guard across a function call that might re-enter the same cell. Runtime panic.
NEVER call borrow_mut() while a borrow() from the same cell is alive in any code path. Same panic.
OnceCell / OnceLock / LazyLock: lazy initialization
For "initialize this exactly once, then read it forever" patterns. Pre-1.70 / pre-1.80 code used lazy_static! or once_cell crate. Std now ships these natively.
OnceCell<T> (single thread)
Source: https://doc.rust-lang.org/std/cell/struct.OnceCell.html
use std::cell::OnceCell;
let cell: OnceCell<String> = OnceCell::new();
let value: &String = cell.get_or_init(|| "hi".to_string());
!Sync.get_or_init(|| ...)runs the closure exactly once (on first call).
OnceLock<T> (multi thread, stable in 1.70)
Source: https://doc.rust-lang.org/std/sync/struct.OnceLock.html
use std::sync::OnceLock;
static CFG: OnceLock<String> = OnceLock::new();
fn config() -> &'static String {
CFG.get_or_init(|| std::env::var("CFG").unwrap_or_default())
}
Sync(safe to use asstatic).- After init, reads are lock-free.
- Replaces
lazy_static!for one-time initialization.
ALWAYS use OnceLock<T> for thread-safe lazy static initialization on Rust 1.70+.
NEVER use static mut for lazy globals. Reading static mut in safe code is forbidden in edition 2024 (static_mut_refs is hard error). OnceLock is the safe replacement.
LazyLock<T> (multi thread, stable in 1.80)
Source: https://doc.rust-lang.org/std/sync/struct.LazyLock.html
LazyLock<T, F> wraps an init closure into the static itself, so the call site looks like a plain &T.
use std::sync::LazyLock;
static GREETING: LazyLock<String> = LazyLock::new(|| "hello".to_string());
fn main() {
println!("{}", *GREETING);
}
- Deref to
&T. Noget_or_initboilerplate at call sites. - Single-thread variant is
LazyCell<T, F>(also stable in 1.80). - Internally a
OnceLockplus the init closure.
ALWAYS prefer LazyLock<T> over OnceLock<T> when the call sites should look like a plain &T with no get_or_init. Use OnceLock<T> when initialization depends on runtime input not available at definition site.
NEVER use the external lazy_static! or once_cell crate in new code on Rust 1.80+. The std types replace them with zero ceremony.
Pin<P>: pinning overview (deep reference under async-await)
Pin<P> is a wrapper around a pointer type P (typically Pin<Box<T>> or Pin<&mut T>) that guarantees the pointee will not be moved in memory for the rest of its lifetime. Required to make self-referential types sound, which is why Future::poll takes self: Pin<&mut Self>.
Source: https://doc.rust-lang.org/std/pin/index.html
use std::pin::Pin;
use std::future::Future;
use std::task::{Context, Poll};
// The Future trait signature: poll receives a pinned mutable reference.
trait FutureSketch {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
Three facts to know in this skill:
Unpinopts a type out of the pinning restriction. Almost all everyday types areUnpin(the trait is an auto-trait). For anUnpintype,Pin<Box<T>>behaves identically toBox<T>andPin<&mut T>behaves identically to&mut T.- The drop guarantee. Once a value is pinned, its drop handler MUST be called before its memory is invalidated. Leaking via
mem::forgetis allowed (it skips drop but never invalidates memory). PhantomPinnedopts a type IN to the pinning restriction. Use this in types that store self-references (typically generated byasync fn, or by hand in custom futures).
ALWAYS treat Pin<P> as a marker that says "this pointer's target cannot be moved". When writing your own futures, see the deep reference: [[rust-syntax-async-await]]/references/pinning.md.
NEVER attempt to extract a &mut T out of a Pin<&mut T> for T: !Unpin via Pin::into_inner_unchecked: that breaks the pin guarantee and is unsound. The safe path is Pin::as_mut and pin-projection (see deep reference).
Common compositions
Rc<RefCell<T>>: shared + mutable, single thread
The canonical pattern for graphs, observer registries, interpreters, ECS prototypes in single-threaded code.
use std::rc::Rc;
use std::cell::RefCell;
let shared = Rc::new(RefCell::new(0_i32));
let copy = Rc::clone(&shared);
*shared.borrow_mut() += 1;
*copy.borrow_mut() += 10;
assert_eq!(*shared.borrow(), 11);
ALWAYS keep RefCell::borrow_mut() scopes as small as possible. A panic from "already borrowed" is the most common bug with this pattern.
Arc<Mutex<T>>: shared + mutable, multi thread
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0_u32));
let handles: Vec<_> = (0..4).map(|_| {
let c = Arc::clone(&counter);
thread::spawn(move || {
let mut g = c.lock().unwrap();
*g += 1;
})
}).collect();
for h in handles { h.join().unwrap(); }
assert_eq!(*counter.lock().unwrap(), 4);
ALWAYS hold MutexGuard for the shortest critical section possible. Drop before any unrelated work.
NEVER hold a std::sync::Mutex guard across .await. Use tokio::sync::Mutex in async code.
Quick reference table
| Type | Send | Sync | Cost | Use when |
|---|---|---|---|---|
Box<T> |
iff T: Send |
iff T: Sync |
one heap alloc | heap-owned single value, recursive types, trait objects |
Rc<T> |
NO | NO | non-atomic refcount | shared read-only, single thread |
Weak<T> (rc) |
NO | NO | non-atomic | break Rc cycles |
Arc<T> |
iff T: Send + Sync |
iff T: Send + Sync |
atomic refcount | shared read-only, multi thread |
Weak<T> (sync) |
iff T: Send + Sync |
iff T: Send + Sync |
atomic | break Arc cycles |
Cell<T> |
iff T: Send |
NO | zero | Copy interior, single thread |
RefCell<T> |
iff T: Send |
NO | runtime borrow check | &/&mut through &Self, single thread |
OnceCell<T> |
iff T: Send |
NO | one-time init | lazy single-thread |
OnceLock<T> |
iff T: Send + Sync |
iff T: Send + Sync |
one CAS + atomic load | lazy multi-thread, since 1.70 |
LazyLock<T, F> |
iff T, F: Send |
iff T: Send + Sync, F: Send |
one-time init + atomic load | lazy multi-thread static, since 1.80 |
Pin<P> |
depends on P |
depends on P |
none | self-referential types, futures |
For full method signatures see methods. For end-to-end examples see examples. For pitfalls see anti-patterns.
Decision tree: which smart pointer
Need shared ownership?
|
+-- No: do you need heap allocation?
| |
| +-- Yes (recursive type, dyn Trait, large value): Box<T>
| |
| +-- No (small Copy type): just T on the stack
|
+-- Yes: single thread or multi thread?
|
+-- Single thread: Rc<T> (+ RefCell<T> if mutation needed)
| |
| +-- Need to break a cycle? Weak<T> for the back-edge.
|
+-- Multi thread: Arc<T> (+ Mutex/RwLock/Atomic if mutation needed)
|
+-- Mutating a primitive int/bool/pointer? AtomicX, no Mutex.
+-- Many readers, occasional writer? RwLock.
+-- Otherwise: Mutex.
+-- Need to break a cycle? sync::Weak<T>.
Need a lazily-initialized value?
|
+-- Single thread, manual get_or_init: OnceCell<T>
+-- Single thread, deref-style global: LazyCell<T, F> (1.80+)
+-- Multi thread, manual get_or_init: OnceLock<T> (1.70+)
+-- Multi thread, deref-style static: LazyLock<T, F> (1.80+)
ALWAYS follow the tree top to bottom. Picking the wrong primitive shows up as performance, deadlock, or compile-error months later.
Reference links
- API reference: references/methods.md
- Worked examples: references/examples.md
- Anti-patterns and why they fail: references/anti-patterns.md
- Pinning deep dive: [[rust-syntax-async-await]]/references/pinning.md
- Borrow rules and
CellvsRefCellvsMutextaxonomy: [[rust-syntax-borrowing]] - Concurrency patterns (
Arc<Mutex<T>>,Arc<RwLock<T>>, atomics): [[rust-impl-concurrency]] - Underlying memory model and
UnsafeCell: [[rust-core-memory-model]] - Official: https://doc.rust-lang.org/std/boxed/index.html, https://doc.rust-lang.org/std/rc/index.html, https://doc.rust-lang.org/std/sync/index.html, https://doc.rust-lang.org/std/cell/index.html, https://doc.rust-lang.org/std/pin/index.html