Of common problems with shared pointers
There’s one repeating pattern in all C++ code bases I’ve worked with.
shared_ptr
is abused - one way or another. There are many reasons, sometimes people just start
with shared_ptr
instead of unique_ptr
out of laziness, sometimes it’s the
sole, default smart pointer they rely on. Often, it’s a result of many passes
of refactoring and eventual quality degradation with time. This leads to all
sorts of problems but there’s are definitely some repeating patterns.
Circular references
We all know (I hope) about the classic cyclic reference problem that often arises when two objects both mutually own themselves and refer to themselves at the same time. Something along the lines:
|
|
This, of course, inevitably leads to a memory leak as the reference count never drops to zero for both of them.
I’ve seen that often in failed attempts to implement pimpl pattern or
the observer pattern. The easiest way to break the circular reference is
to use std::weak_ptr
instead. The above example can be fixed by changing the
A::b_
to be a std::weak_ptr
:
|
|
Of course, it’s never as simple and plain as in the example above. Most of the time, the ownership hierarchy involves multiple objects creating the reference cycle. The underlying root-cause is the same.
The introduction of smart pointers brought some sort of a general distaste an apprehension towards raw pointers and references as well but this is completely unjustified in my opinion. Raw pointers are perfectly fine to express an interest in a resource but lack of participation in ownership - they shouldn’t be disregarded, treated as legacy or anything like that.
Unpredictable point of destruction
By definition, when using a shared pointer, we are expressing that a given object’s ownership is shared between multiple owners. Natural, well understood consequence of this is that any of the owners is extending the lifetime of the object.
The other implication is that the object’s destruction point is not
predictable. The order in which the shared pointers are destroyed might be
undefined or dependent on external factors, like e.g. timing. As a result,
none of the owners can assume that by dropping their instance of the pointer,
the managed object will indeed get destroyed. The act of resetting the shared_ptr
merely expresses owner’s termination of participation in shared ownership.
What if I hold an instance of shared_ptr
and I want to be sure that the
destruction won’t happen if I drop it? This is an interesting, real world
problem, something that Timur Doumler has quite well discussed in his talk
about C++ in the audio industry:
Long story short, dropping shared_ptr
on a real-time audio thread might break
real-time guarantees therefore it’s unacceptable and must be prevented.
He’s presenting an interesting idea of a ReleasePool
. Objects are added to
ReleasePool
prior to being used on high priority thread - therefore it’s
guaranteed that once the shared_ptr
is dropped there, the destruction won’t
happen.
The ReleasePool
is polled and cleaned on a low priority thread so, it kind of
emulates the garbage collection mechanism.
Here’s the relevant parts from his
presentation.
|
|
Shared pointers and threads
shared_ptr
ownership and threads often lead to a problem that I refer to as
“ownership inversion”. What do I mean by that? Have a look at the following example:
|
|
What’s gonna happen if I run this? The expected result is that it should print “Hello, world!” after two seconds and terminate after three (-ish) more. But here’s what it does:
|
|
A
owns the Timer
which owns the thread t_
. The functor scheduled on the timer
is the only owner of A
once it executes, both A
and Timer
are being destructed
which leads to an attempt to join thread t_
from itself - resulting in an exception being thrown as described in the std::jthread
documentation:
resource_deadlock_would_occur if this->get_id() == std::this_thread::get_id() (deadlock detected).
In other words, the ownership has been transferred from the main thread to the Timer
thread.
How to address that? std::weak_ptr
is merely a mitigation. Once locked on
Timer
thread, the main thread might drop all its shared_ptr
instances
resulting in this problem back again.
The problem is in the ownership model and the lifetime of the objects which is incorrect. In this example,
the lifetime of A
on main thread has to extend beyond anything that’s happening on Timer
thread.
These kind of bugs are unexpected and initially, difficult to spot by just
looking at the code yet very common and indicative of bad ownership model, most
of the time, caused by over reliance on shared_ptrs
.
Conclusion
Ownership model is an important part of software design. Smart pointers are not just simple wrappers for managing resources but define objects lifetime, the dependency graph and ownership model between participating objects and modules.
As mentioned in C++ core guidelines one should prefer unique_ptr
whenever
possible before considering shared_ptr
. This is the rule of thumb I try to
follow and hopefully, with the examples I’ve discussed here, I’ve managed to
convince you to do the same.