Intrusive vs Non-Intrusive Reference Counting in C++

This article examines the core differences between intrusive and non-intrusive reference counting in C++. It breaks down how both memory management strategies function under the hood, compares their memory overhead and performance characteristics, and highlights the trade-offs in flexibility, safety, and API design when choosing between standard solutions like std::shared_ptr and intrusive alternatives like boost::intrusive_ptr.

Fundamentals of Reference Counting

Reference counting is a memory management technique where an object tracks the number of references pointing to it, automatically deallocating its resources when the count drops to zero. In C++, reference counting is implemented primarily through two paradigms: intrusive and non-intrusive.

Non-Intrusive Reference Counting

In non-intrusive reference counting, the reference counter is stored externally to the managed object. The managed class itself has no awareness of how its lifetime is tracked. The standard C++ implementation of this pattern is std::shared_ptr.

  • Control Block Allocation: When creating a std::shared_ptr, an auxiliary structure called a control block is created. This block contains the strong reference counter, the weak reference counter, allocators, and custom deleters.
  • Pointer Size: A std::shared_ptr is typically two pointers in size (16 bytes on a 64-bit architecture): one pointer points to the managed object, and the other points to the control block.
  • Third-Party Compatibility: Because the counter resides externally, non-intrusive smart pointers can wrap any type, including standard library types, primitive types, and legacy third-party library classes, without requiring source code modifications.

Intrusive Reference Counting

In intrusive reference counting, the reference counter is embedded directly inside the managed object. The object itself provides functions to increment and decrement this internal count. A prominent implementation is boost::intrusive_ptr.

  • Memory Layout: The object and the counter share the same memory allocation. No external control block is required.
  • Pointer Size: An intrusive smart pointer holds only a single raw pointer to the object (8 bytes on a 64-bit architecture), making it identical in size to a native C++ raw pointer.
  • Class Requirements: The managed class must be modified to include the counter and the necessary management logic (typically intrusive_ptr_add_ref and intrusive_ptr_release functions), often accomplished via inheritance from a base class.

Key Architectural Differences

1. Memory Overhead and Cache Locality

  • Non-Intrusive: If not using std::make_shared, wrapping a raw pointer in a std::shared_ptr requires two separate dynamic allocations: one for the object and one for the control block. Even with std::make_shared (which allocates the object and control block in one contiguous block), accessing the control block requires traversing an extra pointer indirection if the object pointer is manipulated separately. Furthermore, with std::make_shared, the memory for the object cannot be deallocated until all std::weak_ptr instances are destroyed, because the weak count resides in the same memory block.
  • Intrusive: The object and its counter are always co-located in the same cache line or memory block. Memory deallocation happens immediately when the reference count reaches zero, regardless of any other systems tracking the instance.

2. Reconstructing Smart Pointers from Raw Pointers

  • Non-Intrusive: Creating a std::shared_ptr from a raw pointer creates a new control block. If another std::shared_ptr already owns that object, two separate control blocks will independently manage the same memory, inevitably causing a double-free crash. To safely obtain a std::shared_ptr from inside a member function, the class must inherit from std::enable_shared_from_this.
  • Intrusive: Constructing an intrusive smart pointer from a raw pointer or from this is completely safe at any time. The constructor simply increments the internal counter embedded within the target object, eliminating double-free risks stemming from raw pointer conversions.

3. Interoperability with C APIs

  • Non-Intrusive: Passing objects managed by std::shared_ptr to C-style APIs that require a raw void* context pointer requires storing or leaking the std::shared_ptr elsewhere to keep it alive. Reconstructing the smart pointer inside a C callback is error-prone.
  • Intrusive: Intrusive objects can be cast directly to raw pointers and passed to C callbacks. Inside the callback, the raw pointer can be safely wrapped back into an intrusive smart pointer, or the ref-count functions can be invoked manually from C-compatible wrappers.

Summary Comparison

Feature Non-Intrusive (std::shared_ptr) Intrusive (boost::intrusive_ptr)
Counter Location External control block Inside the object
Smart Pointer Size 2 words (typically 16 bytes) 1 word (typically 8 bytes)
Type Requirements None (works with any type) Must define internal counter logic
Conversion from this / Raw Requires enable_shared_from_this Inherently safe and direct
Weak Pointer Support Built-in via std::weak_ptr Requires custom implementation
Standardization C++ Standard Library (<memory>) External (e.g., Boost) or custom