The Distinction Between Intrusive and Non-Intrusive Smart Pointers in C++: A Deep Dive

The Distinction Between Intrusive and Non-Intrusive Smart Pointers in C++: A Deep Dive

<span>std::shared_ptr</span> is not a silver bullet; uncovering the memory management tool for high-performance scenarios—intrusive pointers

Introduction:<span>std::shared_ptr</span> and its “hidden costs”

Hello, C++ developers. We all love <span>std::shared_ptr</span>, which elegantly solves the memory management problem of shared ownership through reference counting. When we write <span>auto ptr = std::make_shared<MyObject>();</span>, everything feels so safe and beautiful.

But have you ever thought about the cost of this “beauty”?<span>std::shared_ptr</span> comes with a hidden detail that we often overlook: it requires at least two memory allocations (if not using <span>make_shared</span>), one for creating the object itself and another for creating a separate memory area called the “Control Block” to store management information such as reference counts.

This “Control Block” is like an external accessory, separate from our object. This design is known as non-intrusive, as it does not require modifying the definition of the object itself.

However, in scenarios where performance and memory layout are critically demanding, is there a way to eliminate this “external accessory” to reduce memory allocation and improve cache hit rates?

The answer is yes. This is the protagonist of our discussion today—intrusive smart pointers.

Act One: Non-Intrusive Pointers—The Familiar <span>std::shared_ptr</span>

Let’s first delve into our most familiar friend.

How It Works

Non-intrusive pointers separate the managed object from the management information (such as reference counts).

  • Object (<span>MyObject</span>): allocated on the heap, it is unaware of its own reference status.

  • Control Block (<span>ControlBlock</span>): also allocated on the heap, it contains strong reference counts, weak reference counts, custom deleters, etc.

  • <span>std::shared_ptr</span>: its size is typically two pointers, one pointing to the object and one pointing to the control block.

Illustration:The Distinction Between Intrusive and Non-Intrusive Smart Pointers in C++: A Deep Dive

Code Example

#include &lt;iostream&gt;
#include &lt;memory&gt;

class Widget {
public:
    Widget() { std::cout &lt;&lt; "Widget created.\n"; }
    ~Widget() { std::cout &lt;&lt; "Widget destroyed.\n"; }
};

int main() {
    std::shared_ptr&lt;Widget&gt; sp1;
    {
        // First allocation: allocate memory for Widget object
        // Second allocation: allocate memory for control block
        auto sp2 = std::shared_ptr&lt;Widget&gt;(new Widget()); 
        sp1 = sp2;
        std::cout &lt;&lt; "Use count: " &lt;&lt; sp1.use_count() &lt;&lt; std::endl; // Output 2
    }
    std::cout &lt;&lt; "Use count: " &lt;&lt; sp1.use_count() &lt;&lt; std::endl; // Output 1
} // sp1 goes out of scope, Widget is destroyed

Advantages and Disadvantages

  • Advantages:

    • High versatility: can manage any type of object without modifying its source code. You can use it to manage <span>int</span> or a class from a third-party library that you cannot modify.

    • Easy to use: integrated into the standard library, tools like <span>make_shared</span> make usage very convenient and safe.

  • Disadvantages:

    • Performance overhead:

  1. Two memory allocations (when not using <span>make_shared</span>), increasing the risk of memory fragmentation and the time taken for allocation/deallocation.

  2. Poor memory locality: the object and control block are separated in memory, which may lead to an extra cache miss when accessing the reference count.

  • Memory usage: <span>shared_ptr</span> itself is the size of two pointers, larger than a raw pointer.

  • Act Two: Intrusive Pointers—The High-Performance Practitioners

    Intrusive pointers adopt a completely different philosophy:“Management information should be the responsibility of the object itself!” It “intrudes” the reference counter into the data structure of the managed object.

    How It Works

    • Managed Object: must contain its own reference count member. This is typically achieved by inheriting from a common base class.

    • Intrusive Pointer (<span>IntrusivePtr</span>): its size is only a raw pointer, as it knows that by accessing the pointer, it can find the internal reference counter of the object.

    Illustration:The Distinction Between Intrusive and Non-Intrusive Smart Pointers in C++: A Deep Dive

    Code Example (Simplified)

    First, we need a base class with a reference count.

    #include &lt;atomic&gt;
    
    class RefCounted {
    public:
        RefCounted() : m_refCount(0) {}
        virtual ~RefCounted() = default;
    
        void AddRef() { ++m_refCount; }
        void Release() {
            if (--m_refCount == 0) {
                delete this; // Self-destruct
            }
        }
    private:
        std::atomic&lt;int&gt; m_refCount;
    };

    Then, we implement our intrusive pointer <span>IntrusivePtr</span>.

    template&lt;typename T&gt;
    class IntrusivePtr {
    public:
        IntrusivePtr(T* ptr = nullptr) : m_ptr(ptr) {
            if (m_ptr) m_ptr-&gt;AddRef();
        }
        ~IntrusivePtr() {
            if (m_ptr) m_ptr-&gt;Release();
        }
        // Implement copy constructor, copy assignment, move constructor/assignment...
        // ... (omitted for brevity, but the principle is similar for incrementing/decrementing reference counts) ...
    private:
        T* m_ptr;
    };

    Finally, let our <span>GameObject</span> support reference counting in an “intrusive” manner.

    class GameObject : public RefCounted { // Must inherit
    public:
        GameObject() { std::cout &lt;&lt; "GameObject created.\n"; }
        ~GameObject() { std::cout &lt;&lt; "GameObject destroyed.\n"; }
    };
    
    int main() {
        // Only one memory allocation: for the GameObject object
        IntrusivePtr&lt;GameObject&gt; ip1(new GameObject()); 
        {
            IntrusivePtr&lt;GameObject&gt; ip2 = ip1; // Only copy the pointer and increase the reference count
            // ...
        }
    } // ip1 goes out of scope, calls Release, GameObject self-destructs

    (Note: Industrial-grade implementations typically use <span>boost::intrusive_ptr</span>, which decouples through some free functions for greater flexibility.)

    Advantages and Disadvantages

    • Advantages:

      • High performance:

    1. Only one memory allocation: memory is allocated only for the object itself.

    2. Cache-friendly: reference counts and object data are together, leading to higher cache hit rates when accessing them.

  • Small memory footprint: intrusive pointers themselves are only the size of a raw pointer.

  • Can convert from raw pointers: can safely create a new intrusive pointer from a <span>this</span> pointer, while doing so with <span>shared_ptr</span> would lead to disaster.

  • Disadvantages:

    • Intrusiveness: the managed class must modify its own definition to include reference counting logic. Cannot be used for <span>int</span> or classes from third-party libraries.

    • More complex usage: objects must be created on the heap using <span>new</span>. The responsibility for lifecycle management partially returns to the programmer.

    • No support for weak pointers: the standard intrusive model typically does not support weak reference mechanisms like <span>std::weak_ptr</span><code><span> (requires additional implementation).</span>

    Act Three: The Ultimate Showdown—When to Choose Which?

    Feature Non-Intrusive (<span>std::shared_ptr</span>) Intrusive (<span>boost::intrusive_ptr</span>)
    Design Philosophy Separation of object and management Unity of object and management
    Memory Allocation 1 or 2 times (<span>make_shared</span> for 1 time) Always only 1 time
    Memory Usage Pointer itself is larger (2 <span>void*</span>) Pointer itself is small (1 <span>T*</span>)
    Cache Performance May be poor (object and reference count are separated) Better (data locality)
    Versatility Very high, usable for any type Low, limited to specifically designed classes
    Ease of Use Very high, supported by the standard library High, but requires cooperation from the object
    Weak References Supports <span>std::weak_ptr</span> Typically does not support

    Decision Guide

    • In 95% of cases, use <span>std::shared_ptr</span>: In the vast majority of applications, the overhead of <span>std::shared_ptr</span> is negligible, while its versatility and safety are invaluable. It is the default, safe choice.

    • In the remaining 5% of high-performance scenarios, consider intrusive pointers:

    1. Game engines, graphics rendering: require frequent creation and destruction of many small objects (like particles, bullets, scene nodes), where memory allocation and cache performance are bottlenecks.

    2. High-frequency trading, financial systems: require latency at the nanosecond level, where any additional memory indirection and allocation are unacceptable.

    3. Embedded or memory-constrained systems: memory fragmentation and the overhead of pointers need to be strictly controlled.

    4. When you need to create a shared pointer from a <span>this</span> pointer: intrusive pointers can safely do this, while <span>shared_ptr</span> requires the use of <span>std::enable_shared_from_this</span>, which also has its own overhead and limitations.

    Conclusion

    Non-intrusive and intrusive smart pointers represent two different philosophies of resource management in C++. <span>std::shared_ptr</span> is the epitome of “convenience and versatility,” while intrusive pointers pursue “extreme performance and control.”

    Understanding the differences between them is not to suggest abandoning <span>shared_ptr</span> in your projects, but to equip you with a sharper tool in your toolbox when facing real performance bottlenecks. Knowing when to use a sledgehammer and when to use a scalpel is the true value of a senior C++ engineer.

    Leave a Comment