Lifetime implication of make_shared

10 Jan 2022 - John Z. Li

Function template make_shared (and its cousin make_unique if you care) was introduced in C++14 to mainly address a problem related with exception safety. Here is the story, consider the following code:

fun(std::shared_ptr(new std::string("foo")),
    std::shared_ptr<Rhs>(new std::string("bar")));

Before C++17, the order in which function parameters are evaluated is not specified. So, a compiler can transform the above function call to the below equivalent code:

auto p1 = new std::string("some long string literal long enough to defy SSO");
auto p2 = new std::string("some other string literal long enough to defy SSO ");
auto sp1 = std::shared_pointer(p1);
auto sp2 = std::shared_pointer(p2);
fun(sp1, sp2);

The problem with this is that if an exception is thrown during construction of the string pointed by p2, string pointed by p1 will become leaked memory. Function template make_shared was introduced in C++14 to fix this problem. The idea is that when allocation happens inside function make_shared, any exception thrown inside the function will cause stack unwinding of the function thus destroy any temporary objects created inside the function. In a word, using maked_shared leads to exception safe code.

However, this has stopped being true since C++17. Starting from C++17, the language standard requires that (emphasis mine),

In a function call, value computations and side effects of the initialization of every parameter are indeterminately sequenced with respect to value computations and side effects of any other parameter.

Here indeterminately sequenced means that the function parameters can be evaluated in any order but their execution must not overlap. With the new rule, the exception safety merit of make_shared is no longer true. It is another thing about it we want to talk about in this post.

While the standard does not require it, most implementations of make_shared employs an optimization trick to reduce the number of allocations needed. Recall that the following code involves two memory allocation, one allocation for the string, one allocation for the control block of the shared pointer:

auto sp = std::shared_pointer(
    new std::string("a long enough string that defeats Small String Optimization");

Typical implementations of shared_pointer employs an optimization trick that merges the two allocations involved into one, meaning that the control block of a shared pointer and the memory block that contains the managed object reside in the same contiguous memory chuck, which is the result of a single memory allocation. This, though, has implications for lifetime of the managed object.

The formal semantics of shared pointers requires that the lifetime of an object managed by a shred pointer ends when all the copies of the shared pointer expire. This means, weak pointers that refer to the same managed object don’t affect the lifetime of the managed object. Even if the “weak count” of the object is non-zero, the object gets destroyed the moment when the “shared count” of the object hits zero.

However, with the optimization make_shared implementations typically employ, the lifetime guarantee of objects managed by shared pointers is lost. If a shared pointer is created by make_shared, a weak pointer pointing to the object will keep the object alive regardless whether the shared count of the object has hit zero. Hence, the following rules about make_shared:

If on the other hand, it is important to make sure that the destructor of the managed object is called exactly after the last shared pointer pointing to it expires, it is better to use constructors of shared_pointer instead. It is little bit slower but has the correct object lifetime guarantee. This can happen if the correctness of program relies on some side-effects of the managed object’s destructor taking place in exact timings and in well-defined order.