Std::Shared_Ptr Thread Safety Explained

std::shared_ptr thread safety explained

As others have pointed out, you've got it figured out correctly regarding your original 3 questions.

But the ending part of your edit

Calling reset() in thread IV will delete previous instance of A class created in first thread and replace it with new instance? Moreover after calling reset() in IV thread other threads will see only newly created object?

is incorrect. Only d will point to the new A(10), and a, b, and c will continue to point to the original A(1). This can be seen clearly in the following short example.

#include <memory>
#include <iostream>
using namespace std;

struct A
{
int a;
A(int a) : a(a) {}
};

int main(int argc, char **argv)
{
shared_ptr<A> a(new A(1));
shared_ptr<A> b(a), c(a), d(a);

cout << "a: " << a->a << "\tb: " << b->a
<< "\tc: " << c->a << "\td: " << d->a << endl;

d.reset(new A(10));

cout << "a: " << a->a << "\tb: " << b->a
<< "\tc: " << c->a << "\td: " << d->a << endl;

return 0;
}

(Clearly, I didn't bother with any threading: that doesn't factor into the shared_ptr::reset() behavior.)

The output of this code is

a: 1 b: 1 c: 1 d: 1

a: 1 b: 1 c: 1 d: 10

std::shared_ptr thread safety

What you're reading isn't meaning what you think it means. First of all, try the msdn page for shared_ptr itself.

Scroll down into the "Remarks" section and you'll get to the meat of the issue. Basically, a shared_ptr<> points to a "control block" which is how it keeps track of how many shared_ptr<> objects are actually pointing to the "Real" object. So when you do this:

shared_ptr<int> ptr1 = make_shared<int>();

While there is only 1 call to allocate memory here via make_shared, there are two "logical" blocks that you should not treat the same. One is the int which stores the actual value, and the other is the control block, which stores all the shared_ptr<> "magic" that makes it work.

It is only the control block itself which is thread-safe.

I put that on its own line for emphasis. The contents of the shared_ptr are not thread-safe, nor is writing to the same shared_ptr instance. Here's something to demonstrate what I mean:

// In main()
shared_ptr<myClass> global_instance = make_shared<myClass>();
// (launch all other threads AFTER global_instance is fully constructed)

//In thread 1
shared_ptr<myClass> local_instance = global_instance;

This is fine, in fact you can do this in all threads as much as you want. And then when local_instance is destructed (by going out of scope), it is also thread-safe. Somebody can be accessing global_instance and it won't make a difference. The snippet you pulled from msdn basically means "access to the control block is thread-safe" so other shared_ptr<> instances can be created and destroyed on different threads as much as necessary.

//In thread 1
local_instance = make_shared<myClass>();

This is fine. It will affect the global_instance object, but only indirectly. The control block it points to will be decremented, but done in a thread-safe way. local_instance will no longer point to the same object (or control block) as global_instance does.

//In thread 2
global_instance = make_shared<myClass>();

This is almost certainly not fine if global_instance is accessed from any other threads (which you say you're doing). It needs a lock if you're doing this because you're writing to wherever global_instance lives, not just reading from it. So writing to an object from multiple threads is bad unless it's you have guarded it through a lock. So you can read from global_instance the object by assigning new shared_ptr<> objects from it but you can't write to it.

// In thread 3
*global_instance = 3;
int a = *global_instance;

// In thread 4
*global_instance = 7;

The value of a is undefined. It might be 7, or it might be 3, or it might be anything else as well. The thread-safety of the shared_ptr<> instances only applies to managing shared_ptr<> instances which were initialized from each other, not what they're pointing to.

To emphasize what I mean, look at this:

shared_ptr<int> global_instance = make_shared<int>(0);

void thread_fcn();

int main(int argc, char** argv)
{
thread thread1(thread_fcn);
thread thread2(thread_fcn);
...
thread thread10(thread_fcn);

chrono::milliseconds duration(10000);
this_thread::sleep_for(duration);

return;
}

void thread_fcn()
{
// This is thread-safe and will work fine, though it's useless. Many
// short-lived pointers will be created and destroyed.
for(int i = 0; i < 10000; i++)
{
shared_ptr<int> temp = global_instance;
}

// This is not thread-safe. While all the threads are the same, the
// "final" value of this is almost certainly NOT going to be
// number_of_threads*10000 = 100,000. It'll be something else.
for(int i = 0; i < 10000; i++)
{
*global_instance = *global_instance + 1;
}
}

A shared_ptr<> is a mechanism to ensure that multiple object owners ensure an object is destructed, not a mechanism to ensure multiple threads can access an object correctly. You still need a separate synchronization mechanism to use it safely in multiple threads (like std::mutex).

The best way to think about it IMO is that shared_ptr<> makes sure that multiple copies pointing to the same memory don't have synchronization issues for itself, but doesn't do anything for the object pointed to. Treat it like that.

Thread safety with std::shared_ptr

std::shared_ptr instances are not thread safe. Multiple instances all pointing to the same object can be modified from multiple threads but a single instance is not thread safe. See https://en.cppreference.com/w/cpp/memory/shared_ptr:

All member functions (including copy constructor and copy assignment) can be called by multiple threads on different instances of shared_ptr without additional synchronization even if these instances are copies and share ownership of the same object. If multiple threads of execution access the same shared_ptr without synchronization and any of those accesses uses a non-const member function of shared_ptr then a data race will occur; the shared_ptr overloads of atomic functions can be used to prevent the data race.

You therefore either need to lock your mutex in your get_obj method or use std::atomic_load and std::atomic_store in your start and stop methods

What mechanism ensures that std::shared_ptr control block is thread-safe?

There isn't really much machinery required for this. For a rough sketch (not including all the requirements/features of the standard std::shared_ptr):

You only need to make sure that the reference counter is atomic, that it is incremented/decremented atomically and accessed with acquire/release semantics (actually some of the accesses can even be relaxed).

Then when the last instance of a shared pointer for a given control block is destroyed and it decremented the reference count to zero (this needs to be checked atomically with the decrement using e.g. std::atomic::fetch_add's return value), the destructor knows that there is no other thread holding a reference to the control block anymore and it can simply destroy the managed object and clean up the control block.

Question about the thread-safety of using shared_ptr

std::shared_ptr<T> guarantees that access to its control block is thread-safe, but not access to the std::shared_ptr<T> instance itself, which is generally an object with two data members: the raw pointer (the one returned by get()) and the pointer to the control block.

In your code, the same std::shared_ptr<int> instance may be concurrently accessed by the two threads; f1 reads, and f2 writes.

If the two threads were accessing two different shared_ptr instances that shared ownership of the same object, there would be no data race. The two instances would have the same control block, but accesses to the control block would be appropriately synchronized by the library implementation.

If you need concurrent, race-free access to a single std::shared_ptr<T> instance from multiple threads, you can use std::atomic<std::shared_ptr<T>>. (There is also an older interface that can be used prior to C++20, which is deprecated in C++20.)

Thread Safety of Shared Pointers' Control Block

In a multithreaded environment, use_count() is approximate. From cppreference :

In multithreaded environment, the value returned by use_count is approximate (typical implementations use a memory_order_relaxed load)

The control block for shared_ptr is otherwise thread-safe :

All member functions (including copy constructor and copy assignment) can be called by multiple threads on different instances of shared_ptr without additional synchronization even if these instances are copies and share ownership of the same object.

Seeing an out-of-date use_count() is not an indication that the control-block was corrupted by a race condition.

Note that this does not extend to modifying the pointed object. It does not provide synchronization for the pointed object. Only the state of the shared_ptr and the control block are protected.

Is std::move of shared_ptr thread safe?

You have a misconception about what std::move does. In fact std::move does nothing. It's just a compile time mechanism with the meaning: I no longer need this named value.

This then causes the compiler to use the value in different ways, like call a move constructor/assignment instead of copy constructor/assignment. But the std::move itself generates no code so nothing to be affected by threads.

The real question you should be asking is whether the move constructor of shared_ptr is thread safe and the answer is: No.

But the point of the shared_ptr is not to share the shared_ptr but to share what it points too. Do not pass a shared_ptr by reference to threads, instead pass it by value so each thread gets it own shared_ptr. Then it is free to move that around at will without problems.

The shared_ptr protects the lifetime of the thing it points to. The reference count the shared_ptr uses is thread safe but nothing else. It only manages the lifetime of the pointed to objects in a thread safe way so it doesn't get destroyed as long as any shared_ptr has hold of the object.

Thread-safety of reference count in std::shared_ptr

Question 1:

The implementation linked to is not thread-safe at all. You are correct that the shared reference counter should be atomic, not pointers to it. std::atomic<int*> here makes no sense.

Note that just changing std::atomic<int*> to std::atomic<int>* won't be enough to fix this either. For example the destructor is decrementing the reference count and checking it against 0 non-atomically. So another thread could get in between these two operations and then they will both think that they should delete the object causing undefined behavior.

As mentioned by @fabian in the comments, it is also far from a correct non-thread-safe shared pointer implementation. For example with the test case

{
Shared_ptr<int> a(new int);
Shared_ptr<int> b(new int);
b = a;
}

it will leak the second allocation. So it doesn't even do the basics correctly.

Even more, in the simple test case

{
Shared_ptr<int> a(new int);
}

it leaks the allocated memory for the reference counter (which it always leaks).


Question 2:

There is no reason to have a null pointer check there except to avoid printing the message. In fact, if we want to adhere to the standard's specification of std::default_delete for default_deleter, then at best it is wrong to check for nullptr, since that is specified to call delete unconditionally.

But the only possible edge case where this could matter is if a custom operator delete would be called that causes some side effect for a null pointer argument. However, it is anyway unspecified whether delete will call operator delete if passed a null pointer, so that's not practically relevant either.



Related Topics



Leave a reply



Submit