Reinventing From Scratch — Rc<T>
Chapter 6 — Weak<T>: Downgrade, Upgrade, and Drop
Weak<T> is a non-owning handle to the same allocation. It observes the value’s lifetime without keeping it alive. This is the standard tool to break cycles and to “peek” at shared state safely.
6.1 downgrade: create a Weak
impl<T> MyRc<T> {
pub fn downgrade(this: &Self) -> MyWeak<T> {
unsafe {
let b = this.ptr.as_ref();
b.weak.set(b.weak.get().checked_add(1).expect("weak overflow"));
}
MyWeak { ptr: this.ptr, _marker: std::marker::PhantomData }
}
}
6.2 upgrade: try to become an Rc
impl<T> MyWeak<T> {
pub fn upgrade(&self) -> Option<MyRc<T>> {
unsafe {
let b = self.ptr.as_ref();
let s = b.strong.get();
if s == 0 {
None // value already dropped
} else {
b.strong.set(s + 1);
Some(MyRc { ptr: self.ptr, _marker: std::marker::PhantomData })
}
}
}
}
If the strong count is non-zero, the value is alive, and we can atomically (single-threadedly) bump strong and return a new owner. Otherwise, upgrading fails with None.
6.3 Dropping Weak
impl<T> Drop for MyWeak<T> {
fn drop(&mut self) {
unsafe {
let b = self.ptr.as_ref();
let w = b.weak.get();
if w == 1 {
// last weak. If no strongs remain either, free allocation.
if b.strong.get() == 0 {
dealloc_rcbox(self.ptr.as_ptr());
}
b.weak.set(0);
} else {
b.weak.set(w - 1);
}
}
}
}
6.4 Observability and guarantees
upgradenever touches memory after free because the allocation is only freed when both counts reach zero.Weakcan outliveTbut not the allocation: afterstrong == 0, the header remains untilweak == 0.
6.5 Failure modes to avoid
- Freeing the allocation while
weak > 0→ use-after-free inupgrade. - Not freeing when both zero → leak.
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.
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