3

Is a shared pointer (std::shared_ptr) safe to use in a multi-threaded program?
I am not considering read/write accesses to the data owned by the shared pointer but rather the shared pointer itself.

I am aware that certain implementations (such as MSDN) do provide this extra guarantee; but I want to understand if this is guaranteed by the standard and as such is portable.

#include <thread>
#include <memory>
#include <iostream>

void function_to_run_thread(std::shared_ptr<int> x)
{
    std::cout << x << "\n";
}
// Shared pointer goes out of scope.
// Is its destruction here guaranteed to happen only once?
// Or is this a "Data Race" situation that is UB?

int main()
{
    std::thread   threads[2];

    {
        // A new scope
        // So that the shared_ptr in this scope has the
        // potential to go out of scope before the threads have executed.
        // So leaving the shared_ptr in the scope of the threads only.
        std::shared_ptr<int>   data = std::make_shared<int>(5);

        // Create workers.
        threads[0] = std::thread(function_to_run_thread, data);
        threads[1] = std::thread(function_to_run_thread, data);
    }
    threads[0].join();
    threads[1].join();
}

Any links to sections in the standard most welcome.

I would be happy if people have reference to the major implementations so we could consider it portable to most normal developers.

  • MSDN: Check. Thread Safe.
  • G++: ?
  • clang: ?

I would consider those the major implementations but happy to consider others.

Martin York
  • 257,169
  • 86
  • 333
  • 562
  • https://stackoverflow.com/questions/14482830/stdshared-ptr-thread-safety it is from 2013 – ALX23z Jan 05 '21 at 18:29
  • @ALX23z I saw that. But it is referring to s a specific implementation of shared_ptr (MSDN version). I am looking to understand if this is defined in the standard and thus portable. – Martin York Jan 05 '21 at 18:32
  • [cppreference says](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." – Fred Larson Jan 05 '21 at 18:34
  • I’ve tried to give the backed answer [here](https://stackoverflow.com/a/65615682/13782669) – alex_noname Jan 07 '21 at 16:55

3 Answers3

2

I don't have links to the standard. I did check this a long time ago, std::shared_ptr is thread-safe under certain conditions, which summarizes to: every thread should have its own copy. As documented on cppreference:

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.

So just like any other class in the standard, reading from the same instance from multiple threads is allowed. Writing to this instance from 1 thread is not.

int main()
{
    std::vector<std::thread>   threads;

    {
        // A new scope
        // So that the shared_ptr in this scope has the
        // potential to go out of scope before the threads have executed.
        // So leaving the shared_ptr in the scope of the threads only.
        std::shared_ptr<int>   data = std::make_shared<int>(5);

        // Perfectly legal to read access the shared_ptr
        threads.emplace_back(std::thread([&data]{ std::cout << data.get() << '\n'; }));        
        threads.emplace_back(std::thread([&data]{ std::cout << data.get() << '\n'; }));

        // This line will result in a race condition as you now have read and write on the same instance
        threads.emplace_back(std::thread([&data]{ data = std::make_shared<int>(42); }));

        for (auto &thread : threads)
           thread.join();
    }
}

Once we are dealing with multiple copies of the shared_ptr, everything is fine:

int main()
{
    std::vector<std::thread>   threads;

    {
        // A new scope
        // So that the shared_ptr in this scope has the
        // potential to go out of scope before the threads have executed.
        // So leaving the shared_ptr in the scope of the threads only.
        std::shared_ptr<int>   data = std::make_shared<int>(5);

        // Perfectly legal to read access the shared_ptr copy
        threads.emplace_back(std::thread([data]{ std::cout << data.get() << '\n'; }));        
        threads.emplace_back(std::thread([data]{ std::cout << data.get() << '\n'; }));

        // This line will no longer result in a race condition the other threads are using a copy
        threads.emplace_back(std::thread([&data]{ data = std::make_shared<int>(42); }));

        for (auto &thread : threads)
           thread.join();
    }
}

Also destruction of the shared_ptr will be fine, as every thread will call the destructor of the local shared_ptr and the last one will clean up the data. There are some atomic operations on the reference count to ensure this happens correctly.

int main()
{
    std::vector<std::thread>   threads;

    {
        // A new scope
        // So that the shared_ptr in this scope has the
        // potential to go out of scope before the threads have executed.
        // So leaving the shared_ptr in the scope of the threads only.
        std::shared_ptr<int>   data = std::make_shared<int>(5);

        // Perfectly legal to read access the shared_ptr copy
        threads.emplace_back(std::thread([data]{ std::cout << data.get() << '\n'; }));        
        threads.emplace_back(std::thread([data]{ std::cout << data.get() << '\n'; }));

        // Sleep to ensure we have some delay
        threads.emplace_back(std::thread([data]{ std::this_thread::sleep_for(std::chrono::seconds{2}); }));
    }
    for (auto &thread : threads)
       thread.join();
}

As you already indicated, the access to the data in the shared_ptr ain't protected. So similar to the first case, if you would have 1 thread reading and 1 thread writing, you still have a problem. This can be solved with atomics or mutexes or by guaranteeing read-onlyness of the objects.

JVApen
  • 11,008
  • 5
  • 31
  • 67
2

Quoting the latest draft:

For purposes of determining the presence of a data race, member functions shall access and modify only the shared_ptr and weak_ptr objects themselves and not objects they refer to. Changes in use_count() do not reflect modifications that can introduce data races.

So, this is a lot to take in. The first sentence talks about member functions not accessing the pointee, i.e. that accessing the pointee is not thread-safe.

However, then there is the second sentence. Effectively, this forces any operation that would change use_count() (e.g. copy construction, assignment, destruction, calling reset) to be thread-safe - but only as far as they are affecting use_count().

Which makes sense: Different threads copying the same std::shared_ptr (or destroying the same std::shared_ptr) must not cause a data race regarding ownership of the pointee. The internal value of use_count() must be synchronized.

I checked, and this exact wording was also present in N3337, Section 20.7.2.2 Paragraph 4, so it should be safe to say that this requirement has been there since the introduction of std::shared_ptr in C++11 (and was not something introduced later on).

hoffmale
  • 232
  • 3
  • 9
0

shared_ptr (and also weak_ptr) utilizes atomic integer to keep use count, so sharing between threads is safe but of course, access to data still requires mutexes or any other synchronization.

Shine
  • 103
  • 1
  • 4