Custom Smart Pointers to Prevent C++ Memory Cycles
This article explores how game developers design and implement custom
intrusive reference-counting smart pointers to eliminate circular memory
dependencies and avoid memory leaks. While standard solutions like
std::shared_ptr and std::weak_ptr manage
lifetimes dynamically, their decoupled control blocks and atomic
overhead often degrade game performance. By building an intrusive
dual-counter system embedded directly into engine objects, developers
maintain strict ownership semantics, eliminate circular references
between interconnected engine systems, and retain high cache locality
across frame updates.
The Problem: Circular Dependencies in Game Architectures
Game engines heavily rely on bidirectional relationships. Common examples include:
- Scene Graphs: A parent node owns its child nodes, while child nodes need references back to their parent to compute world-space transformations.
- Entity-Component Systems (ECS) and UI: An entity or window container owns its components or child widgets, but components frequently need to query their owning entity.
If both directions use strong reference-counting pointers, neither object's reference count will ever drop to zero, creating a circular reference and causing permanent memory leaks.
Limitations of Standard Smart Pointers in Games
Standard library pointers (std::shared_ptr and
std::weak_ptr) pose specific challenges in game
development:
- Cache Inefficiency: Non-intrusive control blocks are allocated separately from the object payload, causing an extra pointer indirection and cache misses.
- Thread Overhead:
std::shared_ptrenforces atomic operations on reference counters by default, introducing synchronization costs even in single-threaded systems like rendering or gameplay logic. - Fat Pointers: A
std::weak_ptrstores two pointers (one to the object, one to the control block), doubling the memory footprint compared to a raw pointer.
Architectural Solution: Intrusive Dual-Counter System
To eliminate circular dependencies without standard pointer overhead, developers implement custom intrusive smart pointers using two counters: a Strong Reference Count and a Weak Reference Count.
- Strong Pointer (
TStrongPtr<T>): Represents shared ownership. Increments the strong counter. When the strong count hits zero, the managed object is destroyed via its destructor. - Weak Pointer (
TWeakPtr<T>): Represents non-owning observation. Increments the weak counter. A weak pointer does not prevent the object from being destroyed, breaking circular reference loops. - Control Block Integration: Counters can be embedded
directly within the base object class (
RefCountedBase), or managed through a lightweight internal proxy so that memory for the counters outlives the underlying game object until all weak pointers are released.
Core Implementation
1. The Reference Control Block
When handling weak references, the counters must remain valid even after the game object is destroyed. A common pattern separates the counter state into a lightweight counter block that the object owns intrusively.
struct ReferenceController {
unsigned int strong_count{0};
unsigned int weak_count{0};
void add_strong_ref() noexcept {
++strong_count;
}
bool release_strong_ref() noexcept {
return --strong_count == 0;
}
void add_weak_ref() noexcept {
++weak_count;
}
bool release_weak_ref() noexcept {
return --weak_count == 0;
}
};2. The Intrusive Base Class
Game entities inherit from a base class that manages the controller lifecycle:
class RefCounted {
public:
RefCounted() : controller(new ReferenceController()) {
controller->add_strong_ref(); // Initial creation ref
}
virtual ~RefCounted() = default;
ReferenceController* get_controller() const noexcept {
return controller;
}
void add_ref() noexcept {
controller->add_strong_ref();
}
void release() noexcept {
if (controller->release_strong_ref()) {
destroy();
}
}
protected:
virtual void destroy() noexcept {
ReferenceController* ctrl = controller;
delete this; // Invokes destructor
if (ctrl->release_weak_ref()) {
delete ctrl;
}
}
ReferenceController* controller;
};3. The Weak Pointer Implementation
The weak pointer references the controller directly without
incrementing strong_count, eliminating circular
retention.
template <typename T>
class TWeakPtr {
public:
TWeakPtr() : instance(nullptr), controller(nullptr) {}
TWeakPtr(T* ptr) : instance(ptr), controller(ptr ? ptr->get_controller() : nullptr) {
if (controller) {
controller->add_weak_ref();
}
}
~TWeakPtr() {
reset();
}
TWeakPtr(const TWeakPtr& other) : instance(other.instance), controller(other.controller) {
if (controller) {
controller->add_weak_ref();
}
}
void reset() noexcept {
if (controller) {
if (controller->release_weak_ref() && controller->strong_count == 0) {
delete controller;
}
controller = nullptr;
instance = nullptr;
}
}
bool is_valid() const noexcept {
return controller && controller->strong_count > 0;
}
T* get() const noexcept {
return is_valid() ? instance : nullptr;
}
private:
T* instance;
ReferenceController* controller;
};Breaking Cycles in Practice
By pairing strong pointers with weak pointers, engine systems enforce clean ownership hierarchies:
- Parent to Child: The parent node retains ownership
using a strong pointer (
TStrongPtr<Node>), keeping the child alive as long as the parent exists. - Child to Parent: The child stores a
TWeakPtr<Node>pointing back up the tree. Because the weak pointer does not increment the parent's strong count, removing the parent permits its reference count to reach zero. - Destruction: When the parent destructs, it releases
its strong references to its children. With their strong counts now at
zero, the children destruct cleanly. Any dangling weak pointers safely
report
is_valid() == falsebecause the controller tracks that the active instance was destroyed.
Performance Optimizations for Game Engines
To achieve maximum performance with custom reference counting, developers apply the following engine-level strategies:
- Policy-Based Threading: Game developers template
smart pointers with threading policies (e.g.,
SingleThreadedPolicyvs.MultiThreadedPolicy). Gameplay code running entirely on the main thread uses non-atomic arithmetic, avoiding CPU bus-locking penalties. - Arena Allocators for Controllers: Instead of
invoking global
mallocordeleteforReferenceControllerinstances, engines use pooled or slab allocators to keep counter blocks clustered together in cache memory. - Generational Indices as an Alternative: In high-churn systems like particle management or ECS, developers often replace weak pointers entirely with lightweight entity IDs combined with generational counters, sidestepping pointer-tracking overhead altogether.