Why we need the Shared Pointer
Let's look at the following code to understand why we need the shared pointer
blog::point<double>* pt1 = new blog::point<double>(1.2, 2.3, 3.4);
blog::point<double>* pt2 = pt1;
delete pt1;
A pointer pt1
is created using the new
keyword to allocate memory for an object of type blog::point<double>
.
A second pointer pt2
is then set equal to pt1
, so that both pointers point to the same dynamically allocated object.
Later, the delete
keyword is used to deallocate the memory pointed to by pt1
. However, since pt2
also points to
the same memory location, it becomes a dangling pointer pointing to memory that has already been deallocated.
If we were to use pt2
at this point, it would result in undefined behavior that can be difficult to debug.
Using std::shared_ptr
can help avoid this problem by implementing reference counting.
The control block keeps track of how many shared_ptr
objects point to the same dynamically allocated memory,
and only deallocates the memory when the count reaches zero.
This ensures that the memory is not deallocated prematurely while it is still being used by other parts of the program.
The full code for the test is as follows, and you will see the heap-use-after-free bug in it
#include <iostream>
#define dbg_point(pt) std::cout << #pt << " = " << *pt << "\n";
namespace blog {
template<typename T>
struct point {
T x = 0;
T y = 0;
T z = 0;
point(T x_, T y_, T z_)
: x(x_)
, y(y_)
, z(z_) {
}
~point() = default;
friend std::ostream& operator<<(std::ostream& os, const point& pt) {
os << "(" << pt.x << ", " << pt.y << ", " << pt.z << ")";
return os;
}
};
} // namespace blog
void demonstrated_raw_pointer_issue(void) {
blog::point<double>* pt1 = new blog::point<double>(1.2, 2.3, 3.4);
dbg_point(pt1);
blog::point<double>* pt2 = pt1;
delete pt1;
dbg_point(pt2);
}
int main(void) {
demonstrated_raw_pointer_issue();
return 0;
}
$ clang++ -g -fsanitize=address -std=c++17 -o console_app.out main.cpp
$ ./console_app.out
pt1 = (1.2, 2.3, 3.4)
=================================================================
==220910==ERROR: AddressSanitizer: heap-use-after-free on address 0x603000000040 at pc 0x55d11f84cd55 bp 0x7ffe71e78030 sp 0x7ffe71e78028
READ of size 8 at 0x603000000040 thread T0
#0 0x55d11f84cd54 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/00-SharedPointer/console_app.out+0x12fd54)
#1 0x55d11f84dbe5 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/00-SharedPointer/console_app.out+0x130be5)
#2 0x7f7ead5f524d (/nix/store/2j8jqmnd9l7plihhf713yf291c9vyqjm-glibc-2.35-224/lib/libc.so.6+0x2924d) (BuildId: 5f9c93175ddddd93d3113da37a2f99ce5d4ba14a)
#3 0x7f7ead5f5308 (/nix/store/2j8jqmnd9l7plihhf713yf291c9vyqjm-glibc-2.35-224/lib/libc.so.6+0x29308) (BuildId: 5f9c93175ddddd93d3113da37a2f99ce5d4ba14a)
#4 0x55d11f752eb4 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/00-SharedPointer/console_app.out+0x35eb4)
0x603000000040 is located 0 bytes inside of 24-byte region [0x603000000040,0x603000000058)
freed by thread T0 here:
#0 0x55d11f84a817 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/00-SharedPointer/console_app.out+0x12d817)
#1 0x55d11f84cc2e (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/00-SharedPointer/console_app.out+0x12fc2e)
previously allocated by thread T0 here:
#0 0x55d11f849e47 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/00-SharedPointer/console_app.out+0x12ce47)
#1 0x55d11f84cad4 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/00-SharedPointer/console_app.out+0x12fad4)
SUMMARY: AddressSanitizer: heap-use-after-free (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/00-SharedPointer/console_app.out+0x12fd54)
An Introduction to Shared Pointers
We can utilize std::shared_ptr
to avoid the heap-use-after-free bug mentioned in the first section.
Create a std::shared_ptr
named pt1
pointing to a new blog::point<double>
object with
the values (1.2, 2.3, 3.4)
, the reference count of pt1
is 1.
Declare a std::shared_ptr
named pt2
without initializing it,
and assign pt1
to pt2
, the reference counts of both p1 and p2 are equal to 2,
since there are now two shared pointers pointing to the same object.
std::shared_ptr<blog::point<double>> pt1(new blog::point<double>(1.2, 2.3, 3.4));
std::shared_ptr<blog::point<double>> pt2;
pt2 = pt1;
The complete code to test is as follows, and you will see the output correctly, without any bugs.
#include <iostream>
#include <memory>
#define dbg_point(pt) std::cout << #pt << " = " << *pt << "\n";
#define dbg_count(cnt) std::cout << #cnt << " = " << cnt << "\n";
namespace blog {
template<typename T>
struct point {
T x = 0;
T y = 0;
T z = 0;
point(T x_, T y_, T z_)
: x(x_)
, y(y_)
, z(z_) {
}
~point() = default;
friend std::ostream& operator<<(std::ostream& os, const point& pt) {
os << "(" << pt.x << ", " << pt.y << ", " << pt.z << ")";
return os;
}
};
} // namespace blog
void test_shared_pointer(void) {
std::shared_ptr<blog::point<double>> pt1(new blog::point<double>(1.2, 2.3, 3.4));
dbg_point(pt1);
std::shared_ptr<blog::point<double>> pt2;
pt2 = pt1;
dbg_point(pt2);
dbg_count(pt1.use_count());
dbg_count(pt2.use_count());
}
int main(void) {
test_shared_pointer();
return 0;
}
$ clang++ -g -fsanitize=address -std=c++17 -o console_app.out main.cpp
$ ./console_app.out
pt1 = (1.2, 2.3, 3.4)
pt2 = (1.2, 2.3, 3.4)
pt1.use_count() = 2
pt2.use_count() = 2
The Pitfalls of std::shared_ptr
The std::shared_ptr
is a useful tool for managing dynamic memory in C++ programs,
as it provides automatic memory management via reference counting.
However, incorrect usage of std::shared_ptr
can result in a double-free error.
In this section, I will demonstrate how double-free errors can occur with std::shared_ptr
.
By understanding the pitfalls associated with std::shared_ptr
, I think we can better understand
how to use it correctly.
Let's start.
Step 1: Creates a shared pointer named pt1
that points to a newly created object of type
blog::point<double>
initialized with value (1.2, 2.3, 3.4)
, the reference count in the control block is 1.
std::shared_ptr<blog::point<double>> pt1(new blog::point<double>(1.2, 2.3, 3.4));
+-----+ +------------------------------------------+
| pt1 | -----> | new blog::point<double>(1.2, 2.3, 3.4)); |
+-----+ +------------------------------------------+
| 1 |
+-----+
Step 2: Creates a shared pointer name pt2
that initially does not point to any object,
and then assigns it to the same object pointed to by pt1
.
Since they point to the same object, their reference counts both update to 2.
std::shared_ptr<blog::point<double>> pt2;
pt2 = pt1;
+-----+ +------------------------------------------+
| pt1 | -----> | new blog::point<double>(1.2, 2.3, 3.4)); |
+-----+ +------------------------------------------+
| 2 | ^
+-----+ |
|
+-----+ |
| pt2 | -------------------------+
+-----+
| 2 |
+-----+
Step 3: Creates a raw pointer pt3
that points to the same object as pt1
, and then
creates a new shared pointer pt4
that takes ownership of the object pointed to by pt3
.
Notice that the shared pointer pt4
is initialized by the raw pointer pt3
, so the reference count of pt4
is 1
blog::point<double>* pt3 = pt1.get();
std::shared_ptr<blog::point<double>> pt4(pt3);
+-----+ +------------------------------------------+
| pt1 | -----> | new blog::point<double>(1.2, 2.3, 3.4)); | <---- pt3
+-----+ +------------------------------------------+
| 2 | ^ ^
+-----+ | |
| |
+-----+ | | +-----+
| pt2 | -------------------------+ +---------------------| pt4 |
+-----+ +-----+
| 2 | | 1 |
+-----+ +-----+
Step 4: Let's analyze why the code will cause a double-free error
When the shared pointer pt2
is assigned to pt1
, both shared pointers now point to
the same object. When the shared pointers pt1
and pt2
go out of scope at the end of the block
, their reference count decreases, and the object is deleted since it is no longer owned by
any shared pointers.
Then, when the shared pointer pt4
goes out of scope, its reference count also decreases, from 1
to 0
,
and it attempts to delete the same object that has already been released.
So the result will cause a double-free error.
{
std::shared_ptr<blog::point<double>> pt1(new blog::point<double>(1.2, 2.3, 3.4));
std::shared_ptr<blog::point<double>> pt2;
pt2 = pt1;
blog::point<double>* pt3 = pt1.get();
std::shared_ptr<blog::point<double>> pt4(pt3);
}
+------------------------------------------+
| the memory has been released | <---- pt3
+------------------------------------------+
^
|
|
| +-----+
+---------------------| pt4 |
+-----+
| 1 |
+-----+
The double-free testing is the following code.
#include <iostream>
#include <memory>
#define dbg_count(cnt) std::cout << #cnt << " = " << cnt << "\n";
namespace blog {
template<typename T>
struct point {
T x = 0;
T y = 0;
T z = 0;
point(T x_, T y_, T z_)
: x(x_)
, y(y_)
, z(z_) {
}
~point() = default;
friend std::ostream& operator<<(std::ostream& os, const point& pt) {
os << "(" << pt.x << ", " << pt.y << ", " << pt.z << ")";
return os;
}
};
} // namespace blog
void test_shared_pointer(void) {
std::shared_ptr<blog::point<double>> pt1(new blog::point<double>(1.2, 2.3, 3.4));
dbg_count(pt1.use_count());
std::shared_ptr<blog::point<double>> pt2;
pt2 = pt1;
dbg_count(pt1.use_count());
dbg_count(pt2.use_count());
blog::point<double>* pt3 = pt1.get();
std::shared_ptr<blog::point<double>> pt4(pt3);
dbg_count(pt1.use_count());
dbg_count(pt2.use_count());
dbg_count(pt4.use_count());
}
int main(void) {
test_shared_pointer();
return 0;
}
$ clang++ -g -fsanitize=address -std=c++17 -o console_app.out main.cpp
$ ./console_app.out
pt1.use_count() = 1
pt1.use_count() = 2
pt2.use_count() = 2
pt1.use_count() = 2
pt2.use_count() = 2
pt4.use_count() = 1
=================================================================
==185560==ERROR: AddressSanitizer: attempting double-free on 0x603000000040 in thread T0:
#0 0x5614363d7817 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/01-SharedPointer/console_app.out+0x12d817)
#1 0x5614363da700 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/01-SharedPointer/console_app.out+0x130700)
#2 0x5614363dae05 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/01-SharedPointer/console_app.out+0x130e05)
#3 0x7f38f4b6c24d (/nix/store/2j8jqmnd9l7plihhf713yf291c9vyqjm-glibc-2.35-224/lib/libc.so.6+0x2924d) (BuildId: 5f9c93175ddddd93d3113da37a2f99ce5d4ba14a)
#4 0x7f38f4b6c308 (/nix/store/2j8jqmnd9l7plihhf713yf291c9vyqjm-glibc-2.35-224/lib/libc.so.6+0x29308) (BuildId: 5f9c93175ddddd93d3113da37a2f99ce5d4ba14a)
#5 0x5614362dfeb4 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/01-SharedPointer/console_app.out+0x35eb4)
0x603000000040 is located 0 bytes inside of 24-byte region [0x603000000040,0x603000000058)
freed by thread T0 here:
#0 0x5614363d7817 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/01-SharedPointer/console_app.out+0x12d817)
#1 0x5614363da3f4 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/01-SharedPointer/console_app.out+0x1303f4)
#2 0x5614363dae05 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/01-SharedPointer/console_app.out+0x130e05)
previously allocated by thread T0 here:
#0 0x5614363d6e47 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/01-SharedPointer/console_app.out+0x12ce47)
#1 0x5614363d9b79 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/01-SharedPointer/console_app.out+0x12fb79)
#2 0x5614363dae05 (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/01-SharedPointer/console_app.out+0x130e05)
SUMMARY: AddressSanitizer: double-free (/home/gapry/Workspaces/WorkLog/Blog/Lang/Cpp/SharedPointer/01-SharedPointer/console_app.out+0x12d817)
==185560==ABORTING
Building your own shared pointer
As demonstrated, using std::shared_ptr
incorrectly can cause double-free errors.
This can happen if the reference count in the the shared pointer's control block is not
managed properly. Therefore, it is important to learn how to implement a shared pointer from
scratch in order to avoid this pitfall.
We can modify the design of std::shared_ptr
so the control block is no longer a part of
the shared pointer. This can help to ensure that reference counting is handled more reliably and
reduce the likelihood of double-free errors occurring.
Let's look at a possible design
The control_block
is a helper class used to manage the reference count for the shared pointer.
It is an abstract class with a purely virtual destructor, which means it cannot be instantiated by itself.
Instead, it is intended to be used as a base class for other classes, such as the point
class.
The control_block
contains an atomic_int
member called ref_count
,
which is used to keep track of the number of references to the shared pointer.
The friend
keyword is used to allow the shared_ptr
class to access the ref_count
member.
The point
class is a struct
that inherits from the control_block
class.
It allows the reference count to be managed by its own class, not shared pointers.
template<class T>
class shared_ptr;
class control_block {
public:
control_block();
virtual ~control_block() = 0;
template<typename T>
friend class shared_ptr;
private:
std::atomic_int ref_count;
};
template<typename T>
struct point : public control_block;
+------------------------------------------+
| 3 |
+-----+ +------------------------------------------+
| pt1 | -----> | new blog::point<double>(1.2, 2.3, 3.4)); | <---- pt3
+-----+ +------------------------------------------+
^ ^
| |
| |
+-----+ | | +-----+
| pt2 | -------------------------+ +---------------------| pt4 |
+-----+ +-----+
One possible way to define a custom implementation of shared pointer is the following code.
#include <iostream>
#include <atomic>
#define dbg_count(cnt) std::cout << #cnt << " = " << cnt << "\n";
namespace blog {
template<class T>
class shared_ptr;
class control_block {
public:
control_block();
virtual ~control_block() = 0;
template<typename T>
friend class shared_ptr;
private:
std::atomic_int ref_count;
};
control_block::control_block()
: ref_count(0) {
}
control_block::~control_block() {
}
template<class T>
class shared_ptr {
public:
shared_ptr() = default;
shared_ptr(T* const ptr) {
reset(ptr);
}
~shared_ptr() {
reset(nullptr);
}
void operator=(const shared_ptr& rhs) {
reset(rhs.get());
}
T* get() const {
return raw_ptr;
}
void reset(T* const ptr) {
static_assert(std::is_base_of<control_block, T>::value, "");
if (ptr == raw_ptr) {
return;
}
// if raw_ptr isn't nullptr
if (raw_ptr) {
const auto count = --raw_ptr->ref_count;
if (count <= 0) {
delete raw_ptr;
}
}
// the raw_ptr is reset to ptr
raw_ptr = ptr;
// if ptr isn't nullptr
if (raw_ptr) {
raw_ptr->ref_count++;
}
}
int use_count() const {
return raw_ptr->ref_count;
}
private:
T* raw_ptr = nullptr;
};
template<typename T>
struct point : public control_block {
T x = 0;
T y = 0;
T z = 0;
point(T x_, T y_, T z_)
: x(x_)
, y(y_)
, z(z_) {
}
~point() = default;
friend std::ostream& operator<<(std::ostream& os, const point& pt) {
os << "(" << pt.x << ", " << pt.y << ", " << pt.z << ")";
return os;
}
};
} // namespace blog
void test_shared_pointer(void) {
blog::shared_ptr<blog::point<double>> pt1(new blog::point<double>(1.2, 2.3, 3.4));
dbg_count(pt1.use_count());
blog::shared_ptr<blog::point<double>> pt2;
pt2 = pt1;
dbg_count(pt1.use_count());
dbg_count(pt2.use_count());
blog::point<double>* pt3 = pt1.get();
blog::shared_ptr<blog::point<double>> pt4(pt3);
dbg_count(pt1.use_count());
dbg_count(pt2.use_count());
dbg_count(pt4.use_count());
}
int main(void) {
test_shared_pointer();
return 0;
}
$ clang++ -g -fsanitize=address -std=c++17 -o console_app.out main.cpp
$ ./console_app.out
pt1.use_count() = 1
pt1.use_count() = 2
pt2.use_count() = 2
pt1.use_count() = 3
pt2.use_count() = 3
pt4.use_count() = 3
Conclusion
In this article we learned about shared pointer in C++. Why do we need it? How do we use it? What is the potential pitfall? And how to implement it from scratch if we want to. Thanks for reading, see you soon.