C++ Shared Pointer Thread-Safety

Introduction

The C++ std::shared_ptr is a smart pointer that ensures the object it points to is automatically deallocated when the last std::shared_ptr pointing to the object is destroyed. The std::shared_ptr uses automatic reference counting to track the number of std::shared_ptr instances pointing to the object. The reference count increments and decrements are thread-safe because the it is implemented using atomic operations. However, accessing the object itself is not thread-safe if the object itself is not thread-safe. It’s the user’s responsibility to ensure the object access is thread-safe and mutual exclusion locks are often used.

In this blog post, I will implement a simplified custom version of the std::shared_ptr, explain how the reference counting is thread-safe, and discuss the thread safety for object access.

C++ Custom Shared Pointer Implementation and Thread Safety

The following code implements a simplified custom version of the std::shared_ptr. The reference counting is thread-safe because the reference count is implemented using std::atomic. When accessing the objects from multiple threads, mutual exclusion std::mutex locks are used to ensure the object access is thread-safe.

custom_shared_ptr_thread_safe.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <atomic>
#include <cassert>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

template <typename T>
class SharedPtr
{
public:
SharedPtr(T* ptr) : m_ptr{ptr}, m_ref_count(new std::atomic<size_t>{1}) {}

SharedPtr(SharedPtr const& other)
: m_ptr{other.m_ptr}, m_ref_count{other.m_ref_count}
{
m_ref_count->fetch_add(1);
}

SharedPtr& operator=(SharedPtr const& other)
{
if (this != &other)
{
if (m_ref_count->fetch_sub(1) == 1)
{
delete m_ptr;
delete m_ref_count;
}
m_ptr = other.m_ptr;
m_ref_count = other.m_ref_count;
m_ref_count->fetch_add(1);
}
return *this;
}

~SharedPtr()
{
if (m_ref_count->fetch_sub(1) == 1)
{
delete m_ptr;
delete m_ref_count;
}
}

T* get() const noexcept { return m_ptr; }
size_t use_count() const noexcept { return *m_ref_count; }

private:
T* m_ptr;
std::atomic<size_t>* m_ref_count;
};

int main()
{
SharedPtr<int> const ptr{new int{0}};
size_t const num_threads{4};
std::vector<std::thread> threads(num_threads);

// Use a mutex to ensure the operation to the object that the shared pointer
// points to is thread-safe.
std::mutex mtx{};
for (auto& thread : threads)
{
thread = std::thread(
[ptr, &mtx]()
{
std::lock_guard<std::mutex> lock{mtx};
++(*ptr.get());
});
}
for (auto& thread : threads)
{
thread.join();
}

assert(*ptr.get() == num_threads);

return 0;
}

To build and run the program, please run the following commands.

1
2
$ g++ custom_shared_ptr_thread_safe.cpp -o custom_shared_ptr_thread_safe
$ ./custom_shared_ptr_thread_safe

Thread Safety for Shared Automatic Reference Counting

To ensure increasing or decreasing the reference count of a shared pointer is thread-safe, the reference count is implemented using std::atomic. Mutual exclusion locks can also be used for the implementation theoretically. However, because it’s usually less efficient than std::atomic, std::atomic is the preferred choice.

Thread Safety for Object Destruction and Deallocation

Based on the implementation of the destructor of the shared pointer, the object will only be destroyed when the reference count of the shared pointer is decreased to zero.

1
2
3
4
5
6
7
8
~SharedPtr()
{
if (m_ref_count->fetch_sub(1) == 1)
{
delete m_ptr;
delete m_ref_count;
}
}

An interesting question is, will an object the shared pointer points to ever be destroyed and deallocated twice? The can only happen, before the object is destroyed and deallocated in one thread, the shared pointer is copied by another thread, and thus resulting in a shared pointer pointing to an invalid object. Specifically,

1
2
3
4
5
6
7
8
9
~SharedPtr()
{
if (m_ref_count->fetch_sub(1) == 1)
{
// When one thread is executing here, can another thread make a copy of the shared pointer and increase the reference count?
delete m_ptr;
delete m_ref_count;
}
}

The answer is no. Because when the destructor of the last remaining shared pointer whose reference count is 1 is being called, the shared pointer has already become out of scope in the source code and no other threads can copy it anymore. Therefore, the object that the shared pointer points to will never be destroyed and deallocated twice.

For example, the following code will not cause the object to be destroyed twice.

1
2
3
4
5
6
7
8
9
{
SharedPtr<int> const ptr{new int{0}};
...
}
// ptr is out of scope.
// If its reference count is 1, indicating it's the last shared pointer, a new copy of ptr cannot be made anymore.
// The compiler will throw an error if the user tries to make a copy of ptr here.
// Assuming the object that ptr points to is being destroyed and deallocated here.
// It's impossible to make a copy of ptr by another thread here.

Thread Safety for Objects Access

There is no thread safety for objects access. The shared pointer only ensures the reference count of the object is thread safe. The user needs to use mutex locks to ensure the objects access is thread safe if the object itself guarantees no thread safety.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::mutex mtx{};
for (auto& thread : threads)
{
thread = std::thread(
[ptr, &mtx]()
{
std::lock_guard<std::mutex> lock{mtx};
++(*ptr.get());
});
}
for (auto& thread : threads)
{
thread.join();
}

References

Author

Lei Mao

Posted on

11-01-2024

Updated on

11-01-2024

Licensed under


Comments