C++ Circular References: shared_ptr Leaks and Breaking

Key takeaways

A shared_ptr cycle keeps every strong count at one or more, so no destructor runs. Make the non-owning edge (child to parent, observer, cache entry) a weak_ptr, lock() it before use, and confirm teardown with destructor logs or LeakSanitizer.

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

Introduction: “I used shared_ptr but I still leak”

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
};

Reference counting has one blind spot that garbage collectors in Java or C# do not: it only knows how many owners an object has, not whether any of those owners is reachable from the running program. A tracing collector starts from roots and discovers that an isolated island of objects is garbage. shared_ptr never performs that walk. When node A owns B and B owns A, both counts stay at one forever after the last outside handle disappears, and the island is simply lost.

This article covers:

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

Circular references

Reference counting refresher

Copying shared_ptr increases the strong count; reset/destruction decreases it. At 0, the managed object is destroyed. The count lives in a separate control block, together with a second weak count and the deleter. That split is what makes weak_ptr possible: the control block can outlive the object it describes.

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.

Walk through the counts to see why. Create alice and bob with make_shared: each count is 1. Set alice->friend = bob and bob->friend = alice: each count is 2. When the function returns, the local variables are destroyed and each count drops to 1. Neither reaches 0, so neither destructor runs, and because the destructor is what would have released the member shared_ptr, nothing ever decrements the remaining count. The cycle does not have to be two objects long; A → B → C → A leaks just the same, which is why longer cycles through containers or callbacks are harder to spot in review.

The most common hidden cycle is a lambda. A member std::function that captures shared_from_this() by value, or a timer callback stored inside the object it refers to, forms a cycle of length one: the object owns the callback, and the callback owns the object.


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';
}

In this snippet p.reset() drops the only strong owner, so the int is destroyed immediately and w.lock() returns an empty shared_ptr; the if body never runs. You cannot dereference a weak_ptr directly, and that is deliberate: the only way in is lock(), which forces you to handle “the object is gone” at every access point.

Breaking cycles

Make one direction of a mutual association a weak_ptr (typically the “back-pointer” or observer side). Deciding which side is a design question, not a mechanical fix: ask which object should be able to keep the other alive. If you pick the wrong edge, the leak disappears but objects start dying early, and you trade a leak for lock() returning null in places that assumed success.


Practical patterns

Parent / child

  • Own children with shared_ptr (or unique ownership at leaves, depending on the design).
  • Child→parent as weak_ptr when the 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. One trap here: calling shared_from_this() inside the constructor fails, because no shared_ptr owns the object yet. Since C++17 it throws std::bad_weak_ptr; before that it was undefined behavior. Do the registration in a static create() function after make_shared has returned.

If the tree is strictly hierarchical and the parent always outlives its children, you can go further: parents own children through unique_ptr and children hold a plain Parent*. That removes the atomic refcount traffic and makes the ownership obvious. Keep weak_ptr for cases where a child may legitimately outlive its parent, for example when a subtree is handed to another thread.

Cache with weak values

Store weak_ptr in a map keyed by name. On lookup, lock(); if expired, recreate and reinsert. The cache then never extends lifetimes: a texture or parsed config stays loaded exactly as long as some user holds it. Expired entries still occupy map slots, so either prune them on insert or use a custom deleter that erases the key (being careful not to erase a newer entry with the same key).

Observer lists

Observers as weak_ptr so subjects do not keep observers alive forever; prune expired() entries periodically. During notification, lock each entry into a local shared_ptr first, so an observer cannot be destroyed halfway through its own callback by another thread.

In my experience the cycles that survive code review are rarely the textbook prev/next pair. They are event subscriptions: a view subscribes to a model with a lambda that captures shared_from_this(), the model stores that lambda, and the view also holds the model. Everything works, tests pass, and the only symptom is that closing a window no longer lowers memory usage. Capturing a weak_ptr in the lambda and locking it at the top of the callback fixes this class of bug for good.


Debugging

  • Log use_count() when diagnosing surprising lifetimes. Treat it as a debugging hint only; in multithreaded code the value can change right after you read it.
  • ~T() logging: if a destructor is never called, suspect cycles or forgotten releases.
  • LeakSanitizer / Valgrind for heap leak reports.

With Clang or GCC, building with -fsanitize=address -g enables LeakSanitizer on Linux by default, and the report at exit lists the allocation stack for each leaked block. A cycle typically shows one “Direct leak” (the control block allocated by make_shared) and several “Indirect leak” entries for memory reachable only from it. The allocation stack points at the make_shared call, not at the line that closed the cycle, so you still need to look for which member or captured lambda holds the second reference. Valgrind’s --leak-check=full reports the same blocks as “definitely lost” and “indirectly lost”.


Common mistakes

  • Doubly-linked list with shared_ptr both ways → make prev a weak_ptr (or a raw pointer if the list owns all nodes).
  • Publisher/listener mutual shared_ptr → listener holds weak_ptr.
  • Child holds shared_ptr<Parent> while parent holds shared_ptr → switch child to weak_ptr unless you truly co-own.
  • Checking expired() and then calling lock() → race in threaded code; call lock() once and test the result.

Troubleshooting

Symptom: memory grows unbounded

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

Symptom: crash on shutdown

Usually one of two things: code that stored a raw pointer obtained from .get() after the owner was destroyed, or destruction order surprises once a cycle is broken and objects finally do die. Always go through lock() and test for nullptr, and do not cache the raw pointer beyond the scope of the locked shared_ptr.


Performance note

weak_ptr::lock() is an atomic compare-and-increment on the control block; usually negligible compared to correctness. A subtler cost is memory: with make_shared, the object and control block share one allocation, so outstanding weak_ptrs keep the whole block (including the object’s storage, though not its resources) allocated after destruction. For large objects that are observed by long-lived weak references, constructing with std::shared_ptr<T>(new T) separates the two allocations. 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 value—weak_ptr
Observer—weak_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, never from the constructor.

Review questions

  • Any mutual shared_ptr pairs, including through containers?
  • Any stored lambda that captures shared_from_this() or a shared_ptr to its owner?
  • Back-edges use weak_ptr, and lock() results are checked?
  • Do tests confirm destructors run when a graph is torn down?

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 destructor logging during development.

Next: Deepen with the full smart pointers article and memory leak tooling, or browse the C++ series.


Frequently Asked Questions (FAQ)

Q. In a parent/child relationship, which side should hold the weak_ptr?

A. The owning side keeps shared_ptr and the back-reference is weak: the parent holds shared_ptr to its children, and each child holds a weak_ptr to its parent. When the last outside reference to the parent goes away, its use_count drops to zero, the parent is destroyed, and it releases the children. When a child needs its parent, call lock() and check the result, because the parent may already be gone.