← Debugging Guides

阅读中文版本

Rust — Rc Reference Cycles Leak Memory

Diagnose memory growth in Rust from Rc and RefCell cycles that the ownership model cannot free, break cycles with Weak, and verify with leak tests.

intermediatedebuggingrustmemorysmart-pointers

Symptoms

  • RSS grows continuously in a long-running process while valgrind-like tools show no traditional leak
  • Dropping the top-level object does not release its graph: nested nodes persist in heap profiles
  • Structures with parent and child links (trees, caches, observers) grow unboundedly under workload

Possible causes

  • Parent holds Rc<Child> and child holds Rc<Parent>, so strong counts never reach zero
  • An observer stored in a registry holds Rc back to the registry
  • RefCell interior mutability links nodes in both directions after construction
  • Caches keyed by objects that also reference the cache

Diagnosis steps

Confirm the retention with a heap profile

cargo run --release -- --heap-profile 2>&1 | head; or use a dhat-based build

Interpretation

A stable population of the node type after dropping the root shows retention, not allocation churn — the fingerprint of a cycle.

Map the strong-reference edges

grep -Rn "Rc<\|Rc::clone\|upgrade()" src/ | head -20

Interpretation

Each Rc field is an edge; edges forming a closed loop among types are the leak topology.

Break the back-edge and verify

cargo test --release leak_check

Interpretation

Replacing one direction with Weak and asserting drop runs (via Drop impl counting) proves the cycle was the cause.

Root cause

Rc frees memory when the strong count reaches zero. A cycle keeps every member count at one or more even after all external references are dropped, so the ownership model cannot reclaim it: the memory is reachable from itself and no destructor ever runs.

Fix

Designate one direction as weak: children hold Weak<Parent> (or observers hold Weak<Registry>), upgrade() at use time with graceful absence handling, and document the ownership direction. For cache-like graphs, Weak values let entries evaporate when the key object dies.

Risk note

upgrade() returning None is a new failure mode at every call site. Handle it deliberately rather than unwrapping.

Verify the fix

A drop-counting test shows node counts returning to zero after workload; long-running soak tests show RSS plateau instead of growth.

Symptom walkthrough

The leak hides from intuition because each Rc is individually correct:

struct Node {
    parent: RefCell<Option<Rc<Node>>>,   // strong back-edge
    children: RefCell<Vec<Rc<Node>>>,
}

Drop the root and nothing is freed: the root’s children each hold a strong count on it, and it holds strong counts on them. The heap profile shows a stable population of Node allocations after the root is gone — retention without allocation churn, which is the cycle fingerprint. Traditional leak tools stay quiet because the memory is still “reachable”: reachable from itself.

Fix in practice

Pick the ownership direction and demote the back-edge:

use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
    parent: RefCell<Option<Weak<Node>>>,   // weak: no cycle
    children: RefCell<Vec<Rc<Node>>>,      // strong: parent owns children
}

impl Node {
    fn parent(&self) -> Option<Rc<Node>> {
        self.parent.borrow().as_ref().and_then(|w| w.upgrade())
    }
}

The rule of thumb: ownership flows downward (container to contained), navigation flows upward weakly. Caches invert it — strong keys outside, Weak values inside — so entries vanish when the referent dies:

let cache: HashMap<Id, Weak<Asset>> = HashMap::new();

And prove it with a destructor that counts:

impl Drop for Node {
    fn drop(&mut self) { DROPPED.fetch_add(1, Ordering::Relaxed); }
}