Skip to content
academia.sh

Lesson 14 / 14

Reference Cycles

How, when two values hold each other through Rc via RefCell, the strong counter never drops to zero and no record ever appears in the ledger — the course's only leak — is measured. That a weak reference breaks the same reference cycle without raising the counter, and that once broken the ledger produces two records at once, is run; strong and weak counter values are printed.

Published Updated

In this lesson

  • Runs code showing that when two values hold each other through Rc, the strong counter never drops to zero and no record appears in the ledger
  • Proves that a weak reference breaks the reference cycle without raising the strong counter, and that once broken, the ledger produces two records
  • Distinguishes with an example that strong and weak reference counters are tracked independently on the same value
Contents

Across this course’s three topics, over thirteen lessons, a value’s life always fell into one of four classes, and a zeroth option was never seen — even Rc and Arc‘s counter, however many times it was cloned, eventually dropped to zero, sooner or later. This final lesson measures the one case that breaks that very assumption: if two values, through RefCell, each place an Rc inside the other, the strong counter never drops to zero, and no record ever appears in the ledger. Every end measured across the course’s thirteen lessons had a point that was readable from the source and deterministic; this lesson shows that determinism’s own limit — that an end might never happen at all.

The leak measured in the Go curriculum’s Concurrency course was a goroutine that was expected to stop but never did: a running unit was leaking. Here it is not a running unit that leaks but a value — the source code itself ends in a completely ordinary way, the program terminates, but two Node values remain in memory after that end, never dropped.

When Two Values Hold Each Other

The Ledger, Trace, and Drop implementation from the core is used here with the same excerpt. On top of it, exactly one new type is added: Node carries its own Trace and a next: RefCell<Option<Rc<Node>>> field that holds a strong reference to the next node. Two Node values (a, b) are built with Rc::new, then each gives the other a reference with Rc::clone — the very copying operation measured in the Reference Counting lesson, applied here only in both directions at once.

SO13. — the claim that “when two values hold each other, the strong counter never drops to zero even after the scope closes” is confirmed by the ledger length once the scope closes: 0 line in the run below.

// cycle_formation.rs — two values holding each other: the counter never drops to zero, the ledger stays empty
use std::cell::RefCell;
use std::rc::Rc;

type Ledger = Rc<RefCell<Vec<String>>>;

struct Trace {
    name: String,
    ledger: Ledger,
}

impl Trace {
    fn new(name: &str, ledger: &Ledger) -> Trace {
        Trace { name: name.to_string(), ledger: Rc::clone(ledger) }
    }
}

impl Drop for Trace {
    fn drop(&mut self) {
        self.ledger.borrow_mut().push(self.name.clone());
    }
}

struct Node {
    trace: Trace,
    next: RefCell<Option<Rc<Node>>>,
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));

    {
        let a = Rc::new(Node {
            trace: Trace::new("node-a", &ledger),
            next: RefCell::new(None),
        });
        let b = Rc::new(Node {
            trace: Trace::new("node-b", &ledger),
            next: RefCell::new(None),
        });

        *a.next.borrow_mut() = Some(Rc::clone(&b));
        *b.next.borrow_mut() = Some(Rc::clone(&a));

        println!("a name: {}, counter: {}", a.trace.name, Rc::strong_count(&a));
        println!("b name: {}, counter: {}", b.trace.name, Rc::strong_count(&b));
    }

    println!("ledger length once the scope closes: {}", ledger.borrow().len());
}
a name: node-a, counter: 2
b name: node-b, counter: 2
ledger length once the scope closes: 0

While the container is full, the counter is two in both: a‘s own binding is one, the copy held by b.next is one; symmetrically for b. When the scope closes, the a and b bindings end in the reverse of declaration order — the rule built in the Stack and Heap lesson holds here too. But each binding’s end only lowers its own counter by one: when b ends, the copy held by a.next still keeps b’s Node alive; when a ends, the copy held by b.next keeps a’s alive too. What remains is a counter of one in both — it never reaches zero. Node::trace’s Drop is therefore never triggered, because the Node itself is never dropped; the ledger stays empty until the end of the run.

A Weak Reference Does Not Raise the Counter, It Breaks the Reference Cycle

The way to break this reference cycle is to turn one of the two directions into a weak reference: Weak<T> points at the same value as Rc<T> does, but never raises the strong counter. Node splits into two fields here — strong_next keeps one direction strong, weak_previous keeps the other direction weak. Which direction is weak is a design decision; it is not an answer the language gives on its own.

SO14. — the claim that “a weak reference never raises the strong counter” is confirmed by the a name: broken-a, strong: 1, weak: 1 line in the run below: b holds a weak reference to a, and a’s strong counter is still one.

// breaking_with_weak.rs — a weak reference does not raise the counter, and it breaks the reference cycle
use std::cell::RefCell;
use std::rc::{Rc, Weak};

type Ledger = Rc<RefCell<Vec<String>>>;

struct Trace {
    name: String,
    ledger: Ledger,
}

impl Trace {
    fn new(name: &str, ledger: &Ledger) -> Trace {
        Trace { name: name.to_string(), ledger: Rc::clone(ledger) }
    }
}

impl Drop for Trace {
    fn drop(&mut self) {
        self.ledger.borrow_mut().push(self.name.clone());
    }
}

struct Node {
    trace: Trace,
    strong_next: RefCell<Option<Rc<Node>>>,
    weak_previous: RefCell<Weak<Node>>,
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));

    {
        let a = Rc::new(Node {
            trace: Trace::new("broken-a", &ledger),
            strong_next: RefCell::new(None),
            weak_previous: RefCell::new(Weak::new()),
        });
        let b = Rc::new(Node {
            trace: Trace::new("broken-b", &ledger),
            strong_next: RefCell::new(None),
            weak_previous: RefCell::new(Weak::new()),
        });

        *a.strong_next.borrow_mut() = Some(Rc::clone(&b));
        *b.weak_previous.borrow_mut() = Rc::downgrade(&a);

        println!("a name: {}, strong: {}, weak: {}", a.trace.name, Rc::strong_count(&a), Rc::weak_count(&a));
        println!("b name: {}, strong: {}, weak: {}", b.trace.name, Rc::strong_count(&b), Rc::weak_count(&b));

        let recovered = b.weak_previous.borrow().upgrade();
        println!("is the way back from b to a successful: {}", recovered.is_some());
    }

    println!("ledger once the scope closes: [{}]", ledger.borrow().join(", "));
}
a name: broken-a, strong: 1, weak: 1
b name: broken-b, strong: 2, weak: 0
is the way back from b to a successful: true
ledger once the scope closes: [broken-a, broken-b]

a’s strong counter stays at one, because only its own binding owns it; b’s weak counter is one, because b.weak_previous looks at it but does not own it. b’s strong counter, on the other hand, is two — its own binding, plus the copy held by a.strong_next. The upgrade() call confirms that the weak reference still looks at a valid value: it returns an Option<Rc<Node>>, and since a is still alive, the result is Some.

SO15. — the claim that “once this reference cycle is broken, the ledger produces two records at once” is confirmed by the ledger: [broken-a, broken-b] in the same run’s last line.

When the scope closes, b ends first and lowers its strong counter from two to one — because a.strong_next still holds a copy, b’s Node is not dropped yet. When a ends, its own counter drops to zero and it is genuinely dropped; this drop first writes its own trace field (broken-a) to the ledger, then, while dropping its own strong_next field, also drops the last strong reference to b — b’s counter drops to zero too, and b is dropped as well (broken-b). The weak reference never enters this chain: no strong counter changes while b.weak_previous is dropped, because it was never counting one to begin with.

The Limiting Measurement: The Compiler Does Not See the Reference Cycle

The difference between the two runs above passes without any rejection in the source. Both cycle_formation.rs and breaking_with_weak.rs compile with the same kinds of lines: Rc::new, Rc::clone, writing to a RefCell field — the very operations used since the Reference Counting lesson (SO5). The Borrow Checker lesson had shown that the checker tests ownership discipline but does not guarantee the code’s correctness; here that same limit widens one step further: the compiler never notices that two Rc values hold each other, because every Rc::clone call is, on its own, a valid operation — a counter increment, permitted regardless of which value it points at.

SO16. — the claim that “source that holds itself in a cycle goes through the exact same compilation path as source that does not” is confirmed by both runs (cycle_formation, breaking_with_weak) compiling without any warning or error.

This is not an exception to the determinism measured across every lesson of the course, it is that determinism’s own limit: the end point is still readable from the source, only this time what is read is that an end will never take place. Breaking a reference cycle is therefore the writer’s responsibility, not the compiler’s; choosing which direction is weak is too — had a been chosen to hold b weakly instead of b holding a weakly, the same break would have happened in the opposite direction, and the result would not have changed.

Closing Table

The fourteen end points measured across fourteen lessons are gathered below. Each row’s class is written in that lesson’s own text; the table does not make a new classification, it brings together what was scattered.

Lesson Measured value Where the life ends Limiting measurement
Stack and Heap scope-1/2/3 end order scope end end order is exactly the reverse of declaration
Ownership and Moves into-function, inner-scope, into-struct where it was moved a move is not a copy; a single record in the ledger
Borrowing borrow-1 (shared + mutable borrow) scope end a borrow does not extend a lifetime, a reference cannot outlive its source
Slices view (list slice) scope end a slice locks not just the range but the whole source
The Borrow Checker push_and_count return value scope end the checker tests access discipline, not correctness
The Lifetime Concept r1, r2, r3 reference range scope end two independent ranges need not nest
Explicit Lifetime Annotations primary_name, other_name scope end an annotation is a request; a body that does not keep the promise is rejected
Lifetime Elision Rules first_three_letters, longer_name scope end elision is a convenience; a signature it does not cover requires an explicit annotation
References in Structs OwningStruct, BorrowingStruct where it was moved a struct cannot carry a reference without a lifetime parameter
Heap-Allocated Single Ownership on-heap-single, list-1..3 with its container the same type can fall into two different classes; the class comes from the operation
Reference Counting shared (Rc) counter: 1, 2, 3, 2 with its container what decides the end is not a copy’s identity but how many remain
Atomic Reference Counting shared (Arc) counter: 1, 2, 3, 2 with its container the atomic counter protects not the data, only the counter
Interior Mutability counter (RefCell) borrow attempts with its container the rule does not disappear, it turns into an Err at runtime
Reference Cycles node-a, node-b (holding each other) never ended the compiler does not see it; the same Rc::clone builds both the sharing and the leak

Summary

  • When two values hold each other through Rc, the strong counter never drops to zero; no record appears in the ledger — the course’s only leak.
  • A weak reference (Weak<T>) points at the same value but never raises the strong counter; keeping one direction weak is enough to break the reference cycle.
  • Once the reference cycle is broken, both values are dropped when the scope closes and the ledger produces two records at once.
  • The compiler never rejects this reference cycle; the Rc::clone and RefCell writing goes through the exact same compilation path as non-reciprocal use.
  • In the Go curriculum’s Concurrency course, what leaked was a running unit; here what leaks is a value — neither comes to an end on its own without an external signal.

Course Wrap-Up

Across fourteen lessons, this course asked a single question: where does a value’s life end? The answer stayed readable from the source throughout — at scope end, where it was moved, at an explicit call, together with its container, or, as seen in this lesson, never. The course’s first topic, Ownership Rules, built these classes and the borrow checker that guards them; its second topic, Lifetimes, gave that check a name (lifetime) and said when it must be written; its third topic, Shared Ownership, carried ownership past singularity into being countable, shareable, and finally modifiable — but at every step the end point was still deterministic, until this lesson showed two values holding onto each other.

Across the course’s thirteen lessons, one concept passed by name only and was left undefined: the Drop trait. Closures and iterators never appeared in this course at all; all three share the same question — what a value can do, a question separate from how long it lives — and the next course opens exactly that question.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close