C++ Smart Pointer Part 02: Shared Pointer

March 31, 2023

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.


Profile picture

Written by Gapry, 魏秋
Twitter | GitHub | Facebook | Mastodon