August 21st, 2023

Inside STL: The different types of shared pointer control blocks

We saw earlier that C++ standard library shared pointers use a control block to manage the object lifetime.

struct control_block
{
    virtual void Dispose() = 0;
    virtual void Delete() = 0;
    std::atomic<unsigned long> shareds;
    std::atomic<unsigned long> refs;
};

The control block has pure virtual methods, so it is up to derived classes to establish how to dispose and delete the control block.

If you ask a shared_ptr to take responsibility for an already-constructed pointer, then you get this:

template<typename T>
struct separate_control_block : control_block
{
    virtual void Destroy() noexcept override
    {
        delete ptr;
    }
    virtual void Delete() noexcept override
    {
        delete this;
    }
    T* ptr;
};

Added to the basic control block is a pointer to the managed object, which is deleted when the last strong reference goes away.

If you use make_shared or allocate_shared, then the control block and the managed object are placed in the same allocation. In that case, the control block looks like this:

template<typename T>
struct combined_control_block : control_block
{
    virtual void Destroy() noexcept override
    {
        ptr()->~T();
    }
    virtual void Delete() noexcept override
    {
        delete this;
    }
    T* ptr() { return reinterpret_cast<T*>(buffer); }

    // This buffer holds a "T"
    [[alignas(T)]] char buffer[sizeof(T)];
};

Added to the basic control block is a buffer suitable for holding a T object. When the shared_ptr is created, a T is placement-constructed in that buffer, and when the last strong reference goes away (Destroy()), it is destructed. Stephan T. Lavavej calls this the “We know where you live” optimization because the control block doesn’t need to store an explicit pointer to the buffer; it can derive it on the fly.

The reality is a little more complicated due to the need to store a deleter and possibly an allocator, but those are typically zero-length objects, so they get stored in a compressed pair with the other members.

In practice, when debugging, you don’t need to look past the reference counts in the control_block. The thing you really care about in the shared_ptr is the pointed-to object. If you ever look at the control block, it’s just to check whether there are any active strong references.

Bonus chatter: For C++20 make_shared<T[]>, there’s another version of the control block that also has a count member which specifies how many objects are in the storage.

Topics
Code

Author

Raymond has been involved in the evolution of Windows for more than 30 years. In 2003, he began a Web site known as The Old New Thing which has grown in popularity far beyond his wildest imagination, a development which still gives him the heebie-jeebies. The Web site spawned a book, coincidentally also titled The Old New Thing (Addison Wesley 2007). He occasionally appears on the Windows Dev Docs Twitter account to tell stories which convey no useful information.

7 comments

Discussion is closed. Login to edit/delete existing comments.

  • Samuel Q

    What benefit is there to using `char buffer[sizeof(T)]` as opposed to directly having the field be `T obj`? (Unfortunately my knowledge of the gritty details of how object lifetimes work is rusty)

    • 紅樓鍮

      Also be careful to align buffer‘s memory address suitably with alignas(T). (And here Raymond seems to have made a typo: there should be no [[...]] surrounding the alignas(T) because alignas is a keyword and not an attribute.)

    • Kalle Niemitalo

      In Delete(), the `delete this;` statement calls the ~combined_control_block() destructor. If there were a `T obj;` data member, then ~combined_control_block() would call the destructor of that data member. But the T instance was already destructed by Destroy() and the second call could corrupt something, e.g. double free.

  • 紅樓鍮

    Actually, the functionality of separate_control_block<T> is subsumed by combined_control_block<std::unique_ptr<T>>, so technically you don’t need the former at all.

    • Aidan Garvey · Edited

      `seperate_control_block` contains a pointer to an object, but `combined_control_block` contains storage for the entire object, which means the control block’s size depends on the size of the object.
      So the former is still needed for constructing a shared pointer which takes ownership of a pre-existing object.

      • 紅樓鍮

        The non-combined allocation version of can use like this:
        <code>
        The takes care to destroy the , which in turn takes care to , so everything adds up.

        Read more
      • Aidan Garvey

        Ah, looks like I didn’t read your original comment closely enough. I missed that you had a unique_ptr in the combined_control_block as opposed to just a `T`. I wonder why the STL uses two different versions.