Introducing mt_shared_ptr<>, a shared pointer for multi-threaded use

Written by

in

Disclaimer: this is a work in progress, it is bound to contain some mistakes, and the code was only briefly tested. Use at your own discretion.

In a previous blog post, ‘std::shared_ptr<> broken?‘, I talked about the problems of using a std::shared_ptr<> from more than one thread at the same time.

There I mentioned that reading from and writing to a single std::shared_ptr<> (stored as a member variable, for example) is unsafe and causes undefined behaviour. To reiterate:

  • A std::shared_ptr<> protects its refcount, but only as long as the same instance of the std::shared_ptr<> is not read and modified by multiple threads at the same time.
  • Maybe needless to say: it does not protect access to the object it points to at all.

So it is safe to read and modify a copy of a std::shared_ptr<> if each thread has its own copy. However, if you need to modify it from two threads at the same time, or even read it from one thread and modify it from another, you need some kind of locking mechanism to do that correctly.

In this post I will introduce a poor man’s version of std::atomic_shared_ptr<>, with a small addition that makes it harder to use incorrectly.

Before we can fix anything, we need something broken, so here it is. First we create a class Foo with a method Bar() that, when called after destruction, has a high likelihood of causing a crash:

class Foo
{
public:
    Foo()
    {
        pint = &count;
    }

    ~Foo()
    {
        pint = nullptr;
    }

    void Bar()
    {
        sink = *pint;   // make sure a Bar() call on a destroyed Foo object will crash the process
    }

private: 
    int sink;
    int count;
    int * pint;
};

Then we access it very frequently in one thread (t1), while assigning it new values in another thread (t2).

#include <iostream>
#include <exception>  
#include <thread>  

#include <memory>
#include <mutex>

int main()
{
    std::shared_ptr<Foo> f = std::make_shared<Foo>();
    bool running = true;
    std::thread t1([&]
    {
        while (running)
        {
            try
            {
                //f->Bar();  // causes crashes (and that is the expected and correct behaviour)
                auto c = f;
                if (c)
                    c->Bar();
            }
            catch (const std::exception& e)
            {
                std::cout << "exception: " << e.what() << "\n";
            }
        }
    });

    std::thread t2([&]
    {
        for (int i = 0; i < 1e6; ++i)
        {
            f = std::make_shared<Foo>();
            f.reset();
        }
    });

    t2.join();
    running = false;
    t1.join();
    std::cout << "All was fine\n";
    return 0;
}

The result, as expected: a crash (on Windows, an ‘application has stopped working’ dialog).

To fix this, we need to change very little:

#include "mt_shared_ptr.h"

int main()
{
    mt_shared_ptr<Foo> f = std::make_shared<Foo>(); // replaced std::shared_ptr<> with mt_shared_ptr<> 
    bool running = true;
    std::thread t1([&]
    {
        while (running)
        {
            try
            {
                //f->Bar();  // this will now throw instead of crashing
                auto c = f.share();  // added .share() to take an explicit copy
                if (c)
                    c->Bar();
            }
            catch (const std::exception& e)
            {
                std::cout << "exception: " << e.what() << "\n";
            }
        }
    });

    std::thread t2([&]
    {
        for (int i = 0; i < 1e6; ++i)
        {
            f = std::make_shared<Foo>();
            f.reset();
        }
    });

    t2.join();
    running = false;
    t1.join();
    std::cout << "All was fine\n";
    return 0;
}

So how does that work? Inside mt_shared_ptr there is a mutex that gets locked for every operation performed on it. When you call f.reset(), call .share() or reassign it, it locks the mutex first, so access to the internal shared_ptr<> is always exclusive.

I hear some of you thinking: argh, that must be sooo slow. Well, I’m glad you asked 🙂 Let’s do some inaccurate and statistically worthless measurements. The 1e6 (1 million) iterations take about 4.0 seconds, compared to 1.5 seconds without it. That means it adds something like 2.5 µs to every access. So I guess you don’t want to do this in a tight loop, but remember that you only have to call share() once to get an exclusive copy of the shared_ptr<>; after that, the overhead is back to what it was with a plain std::shared_ptr<>.

There is one more trick mt_shared_ptr can do: if you call operator-> while the internal shared_ptr is empty, it throws an exception instead of letting it be dereferenced.

Note: as Peter Dimov pointed out, the destruction of the object pointed to by ptr in class mt_shared_ptr should never run while holding the lock. I’ve introduced swap() and local temporary variables in the operator= and reset() methods to make sure that does not happen.

I’ve also removed the locks from the constructors, because only one constructor will ever run, and only once, for any single object.

Note 1: it has been suggested to remove operator->, because consecutive calls could go to different (but valid) objects, such as:

  sp->func1();  // sp refers to object A
  sp->func2();  // sp might refer to object B
  sp->func3();  // sp might refer to object C

Since sp could be reassigned between the calls, you probably wanted:

auto p = sp.share();
p->func1();
p->func2();
p->func3();

I consider this sane, expected behaviour, and you could still write sp.share()->func1(); several times if operator-> was not there (don’t do that). However, I think it is a valid argument that it may provoke incorrect usage (it’s easy to use incorrectly). So I’ve commented out operator->, but left it in the source to allow step-by-step migration of existing code. You can drop in mt_shared_ptr<> as a shared_ptr<> replacement, and the code will fail to compile wherever operator-> was used (lots of places, I expect). Then you start fixing them, and if it turns out to be too much work to do in one go, you can temporarily enable operator-> to make it compile again so you can run your tests.

Note 2: I tried to make mt_shared_ptr<> behave like a regular type, which basically means it has value semantics. However, it is not a regular type, because that would mean we have to:

  • use the rule of five (we do);
  • be equationally complete, that is, operator==() should be implementable as a non-friend, non-member function;
  • in C++11 (or later), provide a specialization of std::hash<> for equationally complete types;
  • implement operator< to provide a total ordering (or specialize std::less<> if a natural total ordering is not available).

One might be tempted to think that ==, != and < operators would be nice to have on class mt_shared_ptr; however, they would really just provoke more incorrect usage.

So there we have it: a sub-optimal but very effective alternative to std::atomic_shared_ptr<> that can be used with any C++11 (and up) compiler.


mt_shared_ptr.h:

/*
Copyright 2018 Jan Wilmans

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files(the "Software"), 
to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions :

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

#include <iostream>
#include <exception>  
#include <thread>  

#include <memory>
#include <mutex>

template <typename T>
class mt_shared_ptr
{
    using ptr_t = std::shared_ptr<T>;
public:
    mt_shared_ptr() = default;
    mt_shared_ptr(const mt_shared_ptr& t) : ptr(t.share()) {}
    mt_shared_ptr(mt_shared_ptr&& other) : ptr(other.ptr)
    {
        other.ptr.reset();
    }

    mt_shared_ptr& operator=(const mt_shared_ptr& other)
    {
        if (this != &other)
        {
            ptr_t p = ptr;
            ptr_t q = other.share();
            std::lock_guard<std::mutex> lock(mutex);
            ptr = q;
        }
        return *this;
    }

    // no locking needed since moving implies the rhs is not accessed
    mt_shared_ptr& operator=(mt_shared_ptr&& other)
    {
        if (this != &other)
        {
            ptr = std::move(other.ptr);
        }
        return *this;
    }


    // we make sure we do not (possibly) run the destructor of the object that ptr references while holding the lock
    mt_shared_ptr & operator=(ptr_t p) // shared_ptr assignment
    {
        std::lock_guard<std::mutex> lock(mutex);
        ptr.swap(p);
        return *this;
    }

    mt_shared_ptr(const ptr_t& p) : ptr(p) {} // constructor taking a shared_ptr<T>

    /*
    ptr_t operator->() const
    {
        std::lock_guard<std::mutex> lock(mutex);
        if (!ptr) throw std::runtime_error("mt_shared_ptr<> nullptr dereference");
        return ptr;
    }
    */

    ptr_t share() const
    {
        std::lock_guard<std::mutex> lock(mutex);
        return ptr;
    }

    void reset()
    {
        ptr_t delayRelease;
        std::lock_guard<std::mutex> lock(mutex);
        delayRelease = ptr;
        ptr.reset();
    }

    void reset(ptr_t p)
    {
        std::lock_guard<std::mutex> lock(mutex);
        ptr.swap(p);
    }

private:
    mutable std::mutex mutex;
    ptr_t ptr;
};

References

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *