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_ptrfundamentals- 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
Observer lists
Observers as weak_ptrshared_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_ptrboth 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 callinglock()→ race in threaded code; calllock()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
| Relation | Strong | Weak |
|---|---|---|
| Parent→child (owning) | shared_ptr / unique_ptr | — |
| Child→parent (non-owning) | — | weak_ptr |
| Cache value | — | weak_ptr |
| Observer | — | weak_ptr |
Rules
- Break cycles with weak_ptr on the non-owning edge.
- lock() before use; handle expiration.
- enable_shared_from_this when a member must hand out
shared_ptrto*this, never from the constructor.
Review questions
- Any mutual shared_ptr pairs, including through containers?
- Any stored lambda that captures
shared_from_this()or ashared_ptrto 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.