0%

Shared and Weak Pointers in C++

This is my learning notes from the course - Mastering C++ Standard Library Features.

Shared Pointers and Weak Pointers

  • the concept of shared ownership
  • What std::shared_ptr and std::make_shared are
  • What std:: weak_ptr is
  • Run-time overhead

Shared Ownership

The reasons of using shared ownership:

  • Some resources need to be shared between multiple owners
  • After acquisition of a resource, multiple objects can take/lose ownership of it
  • When a resource has no more owners, it is automatically release
  • Useful where there isn’t a clear lifetime for a particular resource (for example might depend on run-time conditions)

std::shared_ptr<T> is a copyable template class that represents shared ownership over a dynamically allocated object.

It uses “reference counting“ to keep track of how many alive owners are present and releases the memory when that count reaches zero.

Copying a std::shared_ptr shares ownership.
Moving a std::shared_ptr transfer ownership.

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
#include <iostream>
#include <memory>

struct foo{
foo() { std::count << "foo::foo()\n"; }
~foo() { std::count << "foo::~foo()\n"; }
};

void sharing()
{
std::shared_ptr<foo> keep_alive;

{
// initialize with a new instance of foo allocated on the heap
std::shared_ptr<foo> f0{new foo};

auto f1 = f0;
auto f2 = f0;
auto f3 = f1;
keep_alive = f2;

// `f0`, `f1`, `f2`, and `keep_alive` are all valid here
}

// Even though `f0`, `f1`, and `f2` have been destroyed here,
// the `foo` instance originally created in `f0`'s constructor
// is still alive and accessible from `keep_alive`

void transferring()
{
std::shared_ptr<foo> f0{new foo};
auto f1 = std::move(f0);
}

// Reference: https://en.cppreference.com/w/cpp/memory/shared_ptr

int main()
{
sharing();
transferring();
}
}

In the example above, you can see that although the resource is acquired in a nested scope, it can be kept alive by sharing it with an owner living outside of the scope.

How does std::shared_ptr work?

1
std::shared_ptr<int> sp0{new int{42}};
1
2
3
4
----------------     -------------------------------------     ---------------
| Stack Memory | --> | Control Block (reference counter) | --> | Heap Memory |
| <sp0> | | <shared: 1> | | <int{42}> |
---------------- ------------------------------------- ---------------

std::make_shared

std::make_shared has all the advantages of std::make_unique:

  • Prevents memory leaks due to unspecified order of evaluation
  • Makes code terser and more readable
  • Doesn’t require an explicit new call

std::make_shared has, however, one more very important feature: it prevents an unnecessary additional allocation and improves cache locality.

Reference: http://en.cppreference.com/w/cpp/memory/shared_ptr/make_shared

Consider the following code:

1
std::shared_ptr<int> s0{new int{5}};

The compiler is forced to allocate twice here:

  • one for the int
  • one for the shared_ptr‘s control block

This is wasteful, as both allocations could be coalesced into one.

However, if we use std::make_shared, it allows implementations to only allocate once for both the shared object and the control block.

Consider the following code:

1
auto s1 = std::make_shared<int>(5);

Only one allocation will be made, and it’s good for both the int and the shared_ptr‘s control block.

This is a great optimization because it will prevent an extra allocation and also places both the object and control block in the same memory location, increasing cache locality, and this can have a big impact on performance.

Therefore, the recommendation is to always us std::make_shared when dealing with shared pointers.

Run-Time Overhead

Shared pointer is NOT a zero-cost abstraction and it needs to deal with reference counting and control blocks, which might have a significant cost on your application.

Here’s the example code in C++ and its corresponding assembly output: (You can try the example on Compiler Explorer - here)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <memory>

void f0()
{
int* x = new int{42};
auto y = x;
auto z = x;
delete x;
}

void f1()
{
auto x = std::make_shared<int>(42);
auto y = x;
auto z = x;
}
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
f0():
sub rsp, 8
mov edi, 4
call operator new(unsigned long)
mov esi, 4
add rsp, 8
mov rdi, rax
jmp operator delete(void*, unsigned long)
f1():
push r12
mov edi, 24
push rbp
push rbx
mov ebx, OFFSET FLAT:_ZL28__gthrw___pthread_key_createPjPFvPvE
call operator new(unsigned long)
mov rbp, rax
mov QWORD PTR [rax], OFFSET FLAT:vtable for std::_Sp_counted_ptr_inplace<int, std::allocator<int>, (__gnu_cxx::_Lock_policy)2>+16
movabs rax, 4294967297
mov QWORD PTR [rbp+8], rax
lea r12, [rbp+8]
mov DWORD PTR [rbp+16], 42
test rbx, rbx
je .L16
lock add DWORD PTR [r12], 1
test rbx, rbx
je .L46

In the case of the shared pointer(f1()), even though the C++ code might look similar to f0(), every time we are copying the original shared pointer, what we’re actually doing is accessing the control block and incrementing the reference count.

Every time an instance of a shared pointer is being destroyed, we are again accessing the control block and decrementing the reference count, until finally it reaches zero and the int is deallocated.

As you can see for the assembly, we are doing a lot more work compared to the raw pointer version. This is a very simple demonstration showing that shared pointer is not a zero-cost abstraction and might have a significant performance impact, depending on your application.

Strongly recommend to use Compiler Explorer and also write simple benchmarks to understand what the cost of std::make_shared is and whether or not it is applicable to your library or application.

std::weak_ptr

std::weak_ptr can only be constructed from instances of std::shared_ptr or other weak pointers.

Reference: http://en.cppreference.com/w/cpp/memory/weak_ptr

Weak pointers can be used to check whether or not an object managed by std::shared_ptr is alive:

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
#include <iostream>
#include <cassert>
#include <memory>

void checking_existence()
{
std::weak_ptr<int> wp;

assert(wp.use_count() == 0);
assert(wp.expired());

{
auto sp = std::make_shared<int>(42);
wp = sp;

assert(wp.use_count() == 1);
assert(!wp.expired());

autp sp2 = sp;

assert(wp.use_count() == 2);
assert(!wp.expired());
}

assert(wp.use_count() == 0);
assert(wp.expired());
}

void accessing_objects()
{
// Reference: http://en.cppreference.com/w/cpp/memory/weak_ptr/lock

std::weak_ptr<int> wp;
assert(wp.lock() == nullptr);

auto sp = std::make_shared<int>(42);
wp = sp;
assert(*wp.lock == 42);
}

int main()
{
checking_existence();
accessing_objects();
}

Accessing an object through an std::weak_ptr requires a conversion to std::shared_ptr first as you can see in accessing_objects().

Note that, the interface of weak pointer is explicitly designed to prevent you from accessing the object unless you have a shared pointer to it. Weak pointer can be used to observe the state of a heap-allocated object and if you are aware of its state and you want to access it, you are forced to use the lock member function to get a shared pointer to it and gain access to the object.

What is std::weak_ptr used for?

std::weak_ptr can be used to break dependency cycles of std::shared_ptr.

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
#include <iostream>
#include <cassert>
#include <memory>

void cycle()
{
struct b;

struct a
{
std::shared_ptr<b> _b;
~a() { std::cout << "~a()\n"; }
};

struct b
{
std::shared_ptr<a> _a;
~b() { std::cout << "~b()\n"; }
};

auto sa = std::make_shared<a>();
auto sb = std::make_shared<b>();
sb->_a = sa;
sa->_b = sb;
}

void weak_cycle()
{
struct b;

struct a
{
std::shared_ptr<b> _b;
~a() { std::cout << "~a()\n"; }
};

struct b;
{
std::weak_ptr<a> _a;
~b() { std::cout << "~b()\n"; }
};

auto sa = std::make_shared<a>();
auto sb = std::make_shared<b>();
sb->_a = sa;
sa->_b = sb;
}

int main()
{
std::cout << "cycle()\n";
cycle(); // print nothing (nothing being destroyed)

std::cout << "\nweak_cycle()\n";
weak_cycle();
}

If we run the above code, nothing will print out upon exit from cycle(), meaning nothing is being destroyed and released. The problem is that the shared pointer to b is keeping the b instance alive, and the shared pointer to a is keeping the a instance alive. So, basically the shared pointers are preventing each other from being destroyed and from releasing ownership because they are creating a dependency cycle. This is something that cannot be solved by design with shared pointer as there is no way that the reference count can evaluate to the correct value if there is a dependency cycle.

What we can do instead is design our class in such a way that the ownership is linear and the cycles are handled by using weak pointer, which is a non-owning temporary ownership modeling smart pointer.

In weak_cycle(), again we have struct a and struct b, but there is a major difference, struct a is still containing a shared pointer to b, but struct b is containing a weak pointer to a, breaking the dependency cycle and making b only observe an instance of a. So, it is temporary ownership.

This is one of the most common use cases of weak pointer, but this problem could be solved in other ways, mainly by using a reference, or a raw pointer, or maybe an index. So, weak pointer is a tool in your toolbox that’s very useful, but it’s not always the right tool for the job.

Recap

A std::shared_ptr wraps a reference counting mechanism around a raw pointer. So for each instance of the std::shared_ptr the reference count is increased by one. If two std::shared_ptr objects refer to each other, they will never get deleted because they will never end up with a reference count of zero.

std::weak_ptr points to a std::shared_ptr but does not increase its reference count. This means that the underlying object can still be deleted even though there is a std::weak_ptr reference to it.

The way that std::weak_ptr works is that it can be used to create a std::shared_ptr for whenever one wants to use the underlying object. If, however, the object has already been deleted, then an empty instance of a std::shared_ptr is returned. Since the reference count on the underlying object is not increased with a std::weak_ptr reference, a circular reference will not result in the underlying object not being deleted.

Summary

  • Learned that std::shared_ptr models shared ownership over a dynamically allocated object
  • Learned that it takes ownership of dynamically allocated memory with its constructor, and releases it in destructor
  • Understood that the ownership can be transferred to other instances by using std::move
  • Understood that the ownership can be shared by copying
  • Always used std::make_shared instead of directly instantiate std::shared_ptr to avoid memory leaks and unnecessary extra allocations
  • Learned that std::shared_ptr can have a significant run-time overhead
    -> only use it when necessary
  • Used std::weak_ptr to *”observe”* shared ownership or break cycles