C++ Circular References: shared_ptr Leaks and Breaking Cycles with weak_ptr

C++ Circular References: shared_ptr Leaks and Breaking Cycles with weak_ptr

이 글의 핵심

Fix shared_ptr reference cycles: weak_ptr edges, lock() before use, enable_shared_from_this patterns for trees, caches, and observers—with leak detection tips.

Ownership tradeoffs: shared_ptr vs unique_ptr · smart pointers · leak detection.

Introduction: “I used shared_ptr but I still leak”

“The reference count never hits zero”

std::shared_ptr manages lifetime automatically, but cycles of shared_ptr keep strong counts ≥ 1, so destructors never run.

struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // cycle: use weak_ptr for one direction
};

This article covers:

  • What cycles are and how refcounting interacts
  • weak_ptr fundamentals
  • Patterns: parent/child, caches, observers
  • Debugging leaks and suspicious counts

Table of contents

  1. What is a circular reference?
  2. weak_ptr basics
  3. Practical patterns
  4. Debugging
  5. Summary

1. Circular references

Reference counting refresher

Copying shared_ptr increases the strong count; reset/destruction decreases it. At 0, the managed object is destroyed.

Cycle example (conceptual)

Two Person objects point to each other with shared_ptr. After function scope ends, each object is still reachable from the other → neither destructor runs.


2. weak_ptr

std::weak_ptr observes the same control block but does not increase the strong count.

auto p = std::make_shared<int>(42);
std::weak_ptr<int> w = p;
p.reset();
// w may be expired; use lock() before access
if (auto s = w.lock()) {
    std::cout << *s << '\n';
}

Breaking cycles

Make one direction of a mutual association a weak_ptr (typically the “back-pointer” or observer side).


3. Practical patterns

Parent / child

  • Own children with shared_ptr (or unique ownership at leaves—design-dependent).
  • Child→parent as weak_ptr when parent must not be kept alive solely by children.

Trees often combine enable_shared_from_this to pass shared_from_this() when registering a child.

Cache with weak values

Store weak_ptr<Resource> in a map keyed by name. On lookup, lock(); if expired, recreate and reinsert.

Observer lists

Observers as weak_ptr<Observer> so subjects do not keep observers alive forever; prune expired() entries periodically.


4. Debugging

  • Log use_count() when diagnosing surprising lifetimes.
  • ~T() logging: if never called, suspect cycles or forgotten releases.
  • LeakSanitizer / Valgrind for heap leak reports.

Common mistakes (short)

  • Doubly-linked list with shared_ptr both ways → make prev a weak_ptr.
  • Publisher/listener mutual shared_ptr → listener holds weak_ptr<Publisher>.
  • Child holds shared_ptr<Parent> while parent holds shared_ptr<Child> → switch child to weak_ptr<Parent> unless you truly co-own.

Troubleshooting

Symptom: memory grows unbounded

Check use_count(), review mutual shared_ptr fields, run LSan/Valgrind.

Symptom: crash on shutdown

Using weak_ptr after destruction—always lock() and test for nullptr.


Performance note

weak_ptr::lock() involves atomic operations; usually fine compared to correctness. Prefer clear ownership DAGs over ad-hoc cycles.


Summary

Relationship cheat sheet

RelationStrongWeak
Parent→child (owning)shared_ptr / unique_ptr
Child→parent (non-owning)weak_ptr
Cache valueweak_ptr
Observerweak_ptr

Rules

  1. Break cycles with weak_ptr on the non-owning edge.
  2. lock() before use; handle expiration.
  3. enable_shared_from_this when a member must hand out shared_ptr to *this.

Checklist

  • Any mutual shared_ptr pairs?
  • Back-edges use weak_ptr?
  • lock() results checked?
  • Destructor logs confirm teardown in tests?

  • shared_ptr vs unique_ptr
  • Memory leak detection
  • Smart pointers guide

Keywords

circular reference, shared_ptr leak, weak_ptr, reference counting, LeakSanitizer

Practical tips

  • Review any shared_ptr field pointing “up” or sideways in graphs.
  • Prefer weak_ptr for observers and caches.
  • Add CI tests that construct/destroy graphs and assert destructor side effects (where safe).

Closing

Circular shared_ptr graphs leak because strong counts never reach zero. Model ownership as a DAG, use weak_ptr for back-edges and observers, and validate with sanitizers and use_count() probes during development.

Next: Deepen with the full smart pointers article and memory leak tooling.