Reinventing From Scratch — Rc<T>
Chapter 2 — Allocation Layout & Header Design
We represent an Rc<T> allocation as a single tail-allocated block with a header immediately followed by the value T. This layout is cache-friendly and ensures that cloning and upgrading only touch a small, well-localized structure.
2.1 The header
use std::cell::Cell;
#[repr(C)]
struct RcBox<T> {
strong: Cell<usize>, // number of MyRc<T> owners
weak: Cell<usize>, // number of MyWeak<T> + implicit weak (1 when strong > 0)
value: T, // inlined payload
}
#[repr(C)]gives us a predictable layout: counts first, thenvalue.Cell<usize>provides interior mutability: we can update counts through a shared reference to the header without&mut self.value: Tis tail-allocated:RcBox<T>’s size includes the value’s size; a singleLayout::new::<RcBox<T>>()is sufficient for allocation and deallocation.
2.2 Allocation & deallocation helpers
We allocate and free the entire block with the same Layout. This is crucial: a mismatch is UB.
use std::alloc::{alloc, dealloc, Layout};
fn layout_of_rcbox<T>() -> Layout {
Layout::new::<RcBox<T>>()
}
unsafe fn alloc_rcbox<T>(value: T) -> *mut RcBox<T> {
let layout = layout_of_rcbox::<T>();
let p = alloc(layout) as *mut RcBox<T>;
if p.is_null() { panic!("allocation failed"); }
// Write the header+value in one shot, moving `value` in.
std::ptr::write(p, RcBox {
strong: Cell::new(1),
weak: Cell::new(1), // implicit weak
value,
});
p
}
unsafe fn dealloc_rcbox<T>(p: *mut RcBox<T>) {
dealloc(p.cast(), layout_of_rcbox::<T>());
}
2.3 Why not allocate header and value separately?
We want a single pointer to the block to be the identity of our shared object. A split allocation would involve two frees and extra failure modes if one succeeds and the other fails, complicating recovery on panics.
2.4 Soundness corner
- Never deallocate before dropping
value(once). - Never decrement counts below zero (wrapping
usizewould be catastrophic). - Never double-drop: the code path that drops
valuemust run exactly once per allocation.
2.5 Visualization
+----------------------+
header → | strong: Cell<usize> |
| weak: Cell<usize> |
| value: T | ◀─ single allocation
+----------------------+
Cloning Rc bumps strong; dropping an Rc decrements it and may drop value. Dropping a Weak only touches weak and may free the block if both counts are zero.
Notes & Asides
- Reference counting is deceptively simple: increments are easy; decrements must be exact.
- Always document the invariants in prose before writing unsafe code.
- Prefer small, focused unsafe blocks. Keep them near proofs.
- Think in two layers: value lifetime (strong) vs allocation lifetime (weak).
- Keep an eye on aliasing: shared references to interior-mutable fields are still mediated by
Cell.
Notes & Asides
- Reference counting is deceptively simple: increments are easy; decrements must be exact.
- Always document the invariants in prose before writing unsafe code.
- Prefer small, focused unsafe blocks. Keep them near proofs.
- Think in two layers: value lifetime (strong) vs allocation lifetime (weak).
- Keep an eye on aliasing: shared references to interior-mutable fields are still mediated by
Cell.
Deep Dive: Formal Invariants, Proof Sketches, and Edge Cases
A. Formal Invariants
We maintain the following invariants for every allocation of RcBox<T>:
- I1 (Header Validity): The fields
strongandweakare validusizecounters that never underflow or overflow. - I2 (Value Liveness):
value: Tis initialized iffstrong > 0. Equivalently,strong == 0impliesvaluehas been dropped. - I3 (Allocation Liveness): The allocation is considered live iff
strong + weak > 0. - I4 (Implicit Weak): While
strong > 0, theweakcounter includes an implicit unit that represents the allocation’s ownership by the strong regime. Whenstrongbecomes 0, we consume exactly one unit fromweak. - I5 (Deallocation Condition): The allocation is deallocated iff
strong == 0 && weak == 0. - I6 (Alias Soundness):
get_mutonly returns&mut Twhenstrong == 1. No two&mut Taliases may exist simultaneously. - I7 (Upgrade Correctness):
Weak::upgrade()returnsSomeiff it can soundly incrementstrong(i.e.,strong > 0at the time of the increment). Otherwise it returnsNonewithout touching memory after deallocation.
These invariants are checked conceptually at every mutating operation: clone, drop, downgrade, upgrade, get_mut, and try_unwrap.
B. Proof Sketches
B.1 Single Drop of value
- Let
Sbe the number of strong owners. Only whenStransitions from 1 to 0 do we calldrop_in_place(value). - After the transition,
valueis logically absent (I2). No future path callsdrop_in_place(value)again because all otherDroppaths observeS > 0orS == 0and skip value drop. - Therefore the destructor of
Truns exactly once.
B.2 No Use-After-Free in upgrade
- The allocation is freed only when
S == 0 && W == 0(I5). upgradefirst readsS. IfS == 0, it returnsNoneand does not dereferencevaluenor mutateS.- If
S > 0, it incrementsSbefore producing aMyRc<T>. Since writes toCellare instantaneous w.r.t. this single-threaded model, either the increment happens first (then we are safe) or a concurrent decrement is impossible (no multi-threading). Hence no racing free occurs.
B.3 Safety of get_mut
get_mutrequires unique strong ownership (S == 1) to produce&mut T. If a secondRcexisted,Swould be greater than 1 and the method would refuse, preserving aliasing guarantees (I6).
C. Panic Safety Considerations
- Destructors should not panic. If they do, Rust aborts during unwinding-from-unwinding; our model does not attempt to recover from double-panics.
- Construction is panic-safe because allocation and initialization happen in a single
ptr::writeofRcBox<T>; if panic occurs before storing, the program has not published the pointer. - Reduction operations (
drop) use explicit branches and avoid underflow; we do not use saturating arithmetic to prevent masking logic errors.
D. Edge Cases
- Degenerate types: For
T = ()or other ZSTs, headers still exist and counts still function; the value drop is a no-op, but the ordering invariants remain required. - Self-referential graphs:
Rc<T>does not moveTin memory; address stability is preserved, but cycles still leak. UseWeakfor back-edges. ptr_eqand multiple allocations:ptr_eqonly answers whether twoRcs refer to the same allocation, not whetherTvalues are equal byPartialEq.
E. Micro-bench Hints
- Cloning is a single
Cellincrement; microbench withcriterionacross hot loops. - Avoid creating/dropping
Weakin tight loops unless necessary; it touchesweakeach time. - Prefer
get_mutortry_unwrapwhen ownership is unique; it skips reference-cell borrow machinery.
Worked Example: A Tiny Tree with Parent Weak Links
use std::cell::{RefCell};
#[derive(Debug)]
struct Node {
name: String,
parent: RefCell<MyWeak<Node>>,
children: RefCell<Vec<MyRc<Node>>>,
}
fn make_node(name: &str) -> MyRc<Node> {
MyRc::new(Node {
name: name.into(),
parent: RefCell::new(MyRc::downgrade(&MyRc::new(Node{name:String::new(), parent:RefCell::new(MyWeak{ptr: unsafe{std::mem::transmute(1usize)}, _marker: std::marker::PhantomData}}, children: RefCell::new(vec![])}))), // placeholder, replaced on adopt
children: RefCell::new(vec![]),
})
}
// Helper: adopt `child` into `parent` (strong), set child's parent (weak)
fn adopt(parent: &MyRc<Node>, child: &MyRc<Node>) {
*child.parent.borrow_mut() = MyRc::downgrade(parent);
parent.children.borrow_mut().push(child.clone());
}
The above uses a placeholder trick to satisfy the compiler in an example-only context; in production you would construct nodes in two phases or use
Defaultand then set links.
Walkthrough
- Create
root,child1,child2asMyRc<Node>. - Call
adopt(&root, &child1)andadopt(&root, &child2). - Dropping
rooteventually drops both children once nothing else points at them strongly; theirparentfields areWeakand do not retain the tree.
Debugging Playbook
-
Symptom: Allocation never freed.
Check: Did you forget to consume the implicit weak when last strong dropped? Are there outstandingWeak? -
Symptom: Double free on last
Weak.
Check: Did you decrementweakboth on the last-strong path and again inWeak::dropwithout the implicit-weak protocol? -
Symptom: Use-after-free in
upgrade.
Check: Are you freeing the allocation whileweak > 0? Did you deallocate before decrementing the very lastweak? -
Symptom: UB in
get_mut.
Check: Ensure you only return&mut Twhenstrong == 1at the exact time of check. Avoid creating&mutacross code that might clone anotherRc.
FAQ (Extended)
Q: Why not store counts adjacent to each strong handle, like a decentralized RC?
A: It complicates synchronization even in single-threaded contexts and makes upgrade semantics unclear. Centralized counts in a header give one source of truth.
Q: Can I make Rc<T> Send if T: Send?
A: Not without atomics. The counters themselves would race; Arc<T> exists for this purpose.
Q: What about memory ordering on a single thread?
A: In single-threaded Rust, the memory model reduces to program order for Cell. We rely on the lack of concurrency, not on fences.
Q: Why does weak_count subtract 1 when strong > 0?
A: To hide the implicit weak from public observers and match std’s API expectations.
Q: Are overflow checks necessary?
A: Practically, you won’t reach usize::MAX strong references. We still use checked_add in educational code to make logic errors obvious.
Exercises (With Hints)
-
Design a
make_cyclic_pair()that intentionally creates a leaking 2-node cycle.
Hint: Store strong references in each node’snextfield; verify withdroplogs that destructors never run. -
Prove that
try_unwrapis linear in the size ofT.
Hint: It performs a singleptr::readofTand conditional deallocation; no per-field work beyondDropofTis forced. -
Implement
Rc::make_mutthat clonesTon write ifstrong > 1.
Hint: This requiresT: Cloneand either temporary ownership orRefCell<T>. -
Add debug assertions: ensure that on last-strong drop,
strong == 1andweak >= 1.
Hint: These can bedebug_assert!s to avoid release-mode overhead. -
Benchmark cloning vs upgrading using Criterion.
Hint: Hot loop withb.iter(|| { let _ = a.clone(); })vslet _ = w.upgrade();from a pre-madeWeak.
Suggested Further Reading
- Rustonomicon: Reference Counting and Ownership Models
- The
std::rcsource code (annotated with comments on drop order) - Swift’s ARC vs Rust’s RC: similarities and differences in move semantics
- CPython’s refcounting: GIL-enabled safety vs Rust’s type-level guarantees
Deep Dive: Formal Invariants, Proof Sketches, and Edge Cases
A. Formal Invariants
We maintain the following invariants for every allocation of RcBox<T>:
- I1 (Header Validity): The fields
strongandweakare validusizecounters that never underflow or overflow. - I2 (Value Liveness):
value: Tis initialized iffstrong > 0. Equivalently,strong == 0impliesvaluehas been dropped. - I3 (Allocation Liveness): The allocation is considered live iff
strong + weak > 0. - I4 (Implicit Weak): While
strong > 0, theweakcounter includes an implicit unit that represents the allocation’s ownership by the strong regime. Whenstrongbecomes 0, we consume exactly one unit fromweak. - I5 (Deallocation Condition): The allocation is deallocated iff
strong == 0 && weak == 0. - I6 (Alias Soundness):
get_mutonly returns&mut Twhenstrong == 1. No two&mut Taliases may exist simultaneously. - I7 (Upgrade Correctness):
Weak::upgrade()returnsSomeiff it can soundly incrementstrong(i.e.,strong > 0at the time of the increment). Otherwise it returnsNonewithout touching memory after deallocation.
These invariants are checked conceptually at every mutating operation: clone, drop, downgrade, upgrade, get_mut, and try_unwrap.
B. Proof Sketches
B.1 Single Drop of value
- Let
Sbe the number of strong owners. Only whenStransitions from 1 to 0 do we calldrop_in_place(value). - After the transition,
valueis logically absent (I2). No future path callsdrop_in_place(value)again because all otherDroppaths observeS > 0orS == 0and skip value drop. - Therefore the destructor of
Truns exactly once.
B.2 No Use-After-Free in upgrade
- The allocation is freed only when
S == 0 && W == 0(I5). upgradefirst readsS. IfS == 0, it returnsNoneand does not dereferencevaluenor mutateS.- If
S > 0, it incrementsSbefore producing aMyRc<T>. Since writes toCellare instantaneous w.r.t. this single-threaded model, either the increment happens first (then we are safe) or a concurrent decrement is impossible (no multi-threading). Hence no racing free occurs.
B.3 Safety of get_mut
get_mutrequires unique strong ownership (S == 1) to produce&mut T. If a secondRcexisted,Swould be greater than 1 and the method would refuse, preserving aliasing guarantees (I6).
C. Panic Safety Considerations
- Destructors should not panic. If they do, Rust aborts during unwinding-from-unwinding; our model does not attempt to recover from double-panics.
- Construction is panic-safe because allocation and initialization happen in a single
ptr::writeofRcBox<T>; if panic occurs before storing, the program has not published the pointer. - Reduction operations (
drop) use explicit branches and avoid underflow; we do not use saturating arithmetic to prevent masking logic errors.
D. Edge Cases
- Degenerate types: For
T = ()or other ZSTs, headers still exist and counts still function; the value drop is a no-op, but the ordering invariants remain required. - Self-referential graphs:
Rc<T>does not moveTin memory; address stability is preserved, but cycles still leak. UseWeakfor back-edges. ptr_eqand multiple allocations:ptr_eqonly answers whether twoRcs refer to the same allocation, not whetherTvalues are equal byPartialEq.
E. Micro-bench Hints
- Cloning is a single
Cellincrement; microbench withcriterionacross hot loops. - Avoid creating/dropping
Weakin tight loops unless necessary; it touchesweakeach time. - Prefer
get_mutortry_unwrapwhen ownership is unique; it skips reference-cell borrow machinery.
Worked Example: A Tiny Tree with Parent Weak Links
use std::cell::{RefCell};
#[derive(Debug)]
struct Node {
name: String,
parent: RefCell<MyWeak<Node>>,
children: RefCell<Vec<MyRc<Node>>>,
}
fn make_node(name: &str) -> MyRc<Node> {
MyRc::new(Node {
name: name.into(),
parent: RefCell::new(MyRc::downgrade(&MyRc::new(Node{name:String::new(), parent:RefCell::new(MyWeak{ptr: unsafe{std::mem::transmute(1usize)}, _marker: std::marker::PhantomData}}, children: RefCell::new(vec![])}))), // placeholder, replaced on adopt
children: RefCell::new(vec![]),
})
}
// Helper: adopt `child` into `parent` (strong), set child's parent (weak)
fn adopt(parent: &MyRc<Node>, child: &MyRc<Node>) {
*child.parent.borrow_mut() = MyRc::downgrade(parent);
parent.children.borrow_mut().push(child.clone());
}
The above uses a placeholder trick to satisfy the compiler in an example-only context; in production you would construct nodes in two phases or use
Defaultand then set links.
Walkthrough
- Create
root,child1,child2asMyRc<Node>. - Call
adopt(&root, &child1)andadopt(&root, &child2). - Dropping
rooteventually drops both children once nothing else points at them strongly; theirparentfields areWeakand do not retain the tree.
Debugging Playbook
-
Symptom: Allocation never freed.
Check: Did you forget to consume the implicit weak when last strong dropped? Are there outstandingWeak? -
Symptom: Double free on last
Weak.
Check: Did you decrementweakboth on the last-strong path and again inWeak::dropwithout the implicit-weak protocol? -
Symptom: Use-after-free in
upgrade.
Check: Are you freeing the allocation whileweak > 0? Did you deallocate before decrementing the very lastweak? -
Symptom: UB in
get_mut.
Check: Ensure you only return&mut Twhenstrong == 1at the exact time of check. Avoid creating&mutacross code that might clone anotherRc.
FAQ (Extended)
Q: Why not store counts adjacent to each strong handle, like a decentralized RC?
A: It complicates synchronization even in single-threaded contexts and makes upgrade semantics unclear. Centralized counts in a header give one source of truth.
Q: Can I make Rc<T> Send if T: Send?
A: Not without atomics. The counters themselves would race; Arc<T> exists for this purpose.
Q: What about memory ordering on a single thread?
A: In single-threaded Rust, the memory model reduces to program order for Cell. We rely on the lack of concurrency, not on fences.
Q: Why does weak_count subtract 1 when strong > 0?
A: To hide the implicit weak from public observers and match std’s API expectations.
Q: Are overflow checks necessary?
A: Practically, you won’t reach usize::MAX strong references. We still use checked_add in educational code to make logic errors obvious.
Exercises (With Hints)
-
Design a
make_cyclic_pair()that intentionally creates a leaking 2-node cycle.
Hint: Store strong references in each node’snextfield; verify withdroplogs that destructors never run. -
Prove that
try_unwrapis linear in the size ofT.
Hint: It performs a singleptr::readofTand conditional deallocation; no per-field work beyondDropofTis forced. -
Implement
Rc::make_mutthat clonesTon write ifstrong > 1.
Hint: This requiresT: Cloneand either temporary ownership orRefCell<T>. -
Add debug assertions: ensure that on last-strong drop,
strong == 1andweak >= 1.
Hint: These can bedebug_assert!s to avoid release-mode overhead. -
Benchmark cloning vs upgrading using Criterion.
Hint: Hot loop withb.iter(|| { let _ = a.clone(); })vslet _ = w.upgrade();from a pre-madeWeak.
Suggested Further Reading
- Rustonomicon: Reference Counting and Ownership Models
- The
std::rcsource code (annotated with comments on drop order) - Swift’s ARC vs Rust’s RC: similarities and differences in move semantics
- CPython’s refcounting: GIL-enabled safety vs Rust’s type-level guarantees