Ох уж этот std::make_shared…
C++ Core Guidelines содержат правило R22, предписывающее использовать std: make_shared вместо вызова конструктора std: shared_ptr. В Core Guidelines приводится всего лишь один аргумент за такое решение — экономия на аллокации (и деаллокации).
А если копнуть чуть глубже?
std: make_shared полезный
Почему вообще в STL появился std: make_shared?
Есть канонический пример, в котором конструирование std: shared_ptr из свежесозданного сырого указателя может приводить к утечке памяти:
process(std::shared_ptr(new Bar), foo());
Для вычисления аргументов функции process (…) необходимо вызвать:
— new Bar;
— конструктор std: shared_ptr;
— foo ().
Компилятор может их перемешивать в произвольном порядке, например вот так:
— new Bar;
— foo ();
— конструктор std: shared_ptr.
Если при этом в foo () возникнет исключение — получаем утечку экземпляра Bar.
Ни один из следующих примеров кода не содержит потенциальную утечку (но мы ещё вернёмся к этому вопросу):
auto bar = std::shared_ptr(new Bar);
auto bar = std::shared_ptr(new Bar);
process(bar, foo());
process(std::shared_ptr(new Bar));
Повторюсь: для возникновения потенциальной утечки надо написать именно такой код, как в самом первом примере — одна функция принимает как минимум два параметра, один из которых инициализируется свежесозданным безымянным std: shared_ptr, а второй параметр инициализируется вызовом другой функции, которая может бросать исключения.
А чтобы потенциальная утечка памяти реализовалась — надо ещё два условия:
— чтобы компилятор перемешал вызовы неблагоприятным образом;
— чтобы функция, вычисляющая второй параметр, действительно бросила исключение.
Такой опасный код вряд ли встречается чаще, чем один раз на сто применений std: shared_ptr.
И для компенсации вот этой вот опасности std: shared_ptr был подпёрт костылём под названием std: make_shared.
Чтобы слегка подсластить пилюлю, к описанию std: make_shared в стандарте добавили следующую фразу:
Remarks: Implementations should perform no more than one memory allocation.
Примечание: реализациям следует производить не более одного выделения памяти.
Нет, это не гарантия.
Но на cppreference говорится, что все известные реализации делают именно так.
Это решение направлено на повышение производительности по сравнению с созданием std: shared_ptr с помощью вызова конструктора, требующим минимум две аллокации: одну — для размещения объекта, вторую — для control block.
std: make_shared бесполезный
Начиная с c++17 утечка памяти в том хитром редком примере, ради которого в STL был добавлен std: make_shared, уже невозможна.
Ссылки для изучения:
— Документация на cppreference.com — искать по «until C++17»;
— Глубина кроличьей норы или собеседование по C++ в компании PVS-Studio
— Ещё документация на cppreference.com — пункт 15.
Есть и несколько других случаев, в которых std: make_shared оказывается бесполезен:
#include
class Bar
{
public:
static std::shared_ptr create()
{
// return std::make_shared(); - no build
return std::shared_ptr(new Bar);
}
private:
Bar() = default;
};
int main()
{
auto bar = Bar::create();
return 0;
}
… потому что является variadic template. В общем случае невозможно разобраться, предоставлены ли только параметры для передачи в конструктор, или ещё и deleter.
Хотя можно было бы добавить ещё и std: make_shared_with_custom_deleter…
Хорошо хоть узнаете об этих проблемах в compile time…
std: make_shared вредный
Переходим в рантайм.
#include
#include
class Bar
{
public:
void* operator new(size_t)
{
std::cout << __func__ << std::endl;
return ::new Bar();
}
void operator delete(void* bar)
{
std::cout << __func__ << std::endl;
::delete static_cast(bar);
}
};
int main()
{
auto bar = std::shared_ptr(new Bar);
// auto bar = std::make_shared();
return 0;
}
Вывод в консоль при использовании конструктора std: shared_ptr:
operator new
operator delete
Вывод в консоль при использовании std: make_shared:
отсутствует
А теперь — самое главное, ради чего собственно статья и затевалась.
Удивительно, но факт: то, как std: shared_ptr будет обращаться с памятью, может существенно зависеть от того, как именно он был создан — с помощью std: make_shared или с помощью конструктора!
Почему так происходит?
Потому что «полезная» единая аллокация, производимая std: make_shared, имеет неотъемлемый побочный эффект в виде возникновение лишней связи между control block и управляемым объектом. Они просто не могут быть освобождены по отдельности. А control block обязан жить до тех пор, пока есть хотя бы одна слабая ссылка.
От std: shared_ptr, созданного с помощью конструктора, следует ожидать следующего поведения:
— аллокация управляемого объекта (до вызова конструктора, т.е. на стороне пользователя);
— аллокация блока управления;
— при уничтожении последней сильной ссылки — вызов деструктора управляемого объекта и освобождение занимаемой им памяти; если при этом нет ни одной слабой ссылки — освобождение блока управления;
— при уничтожении последней слабой ссылки при отсутствии сильных ссылок — освобождение блока управления.
А в случае создания с помощью std: make_shared:
— аллокация управляемого объекта и блока управления;
— при уничтожении последней сильной ссылки — вызов деструктора управляемого объекта без освобождения занимаемой им памяти; если при этом нет ни одной слабой ссылки — освобождение блока управления и памяти управляемого объекта;
— при уничтожении последней слабой ссылки при отсутствии сильных ссылок — освобождение блока управления и памяти управляемого объекта.
Создание std: shared_ptr с помощью std: make_shared провоцирует space leak.
Различить в рантайме, как именно был создан экземпляр std: shared_ptr, невозможно.
Перейдём к проверке этого поведения.
Есть очень простой способ — использовать std: allocate_shared с custom allocator, который будет сообщать обо всех обращениях к нему. Но вот распространять полученные таким образом результаты на std: make_shared некорректно.
Более корректный способ — контроль суммарного расхода памяти. Но ни о какой кросс-платформенности тут не идёт речи.
Приведён код для Linux, протестированный на Ubuntu 20.04 desktop x64. Кому интересно повторить это для других платформ — смотрите тут (мои эксперименты с macOs показали, что опция TASK_BASIC_INFO не позволяет отслеживать освобождение памяти, и TASK_VM_INFO_PURGEABLE является более подходящим кандидатом).
#pragma once
#include
uint64_t memUsage();
#include "Monitoring.h"
#include
#include
uint64_t memUsage()
{
auto file = std::ifstream("/proc/self/status", std::ios_base::in);
auto line = std::string();
while(std::getline(file, line)) {
if (line.find("VmSize") != std::string::npos) {
std::string toConvert;
for (const auto& elem : line) {
if (std::isdigit(elem)) {
toConvert += elem;
}
}
return stoull(toConvert);
}
}
return 0;
}
#include
#include
#include
#include
#include "Monitoring.h"
struct Big
{
~Big()
{
std::cout << __func__ << std::endl;
}
std::array _data;
};
volatile uint64_t accumulator = 0;
int main()
{
std::cout << "initial: " << memUsage() << std::endl;
auto strong = std::shared_ptr(new Big);
// auto strong = std::make_shared();
std::accumulate(strong->_data.cbegin(), strong->_data.cend(), accumulator);
auto weak = std::weak_ptr(strong);
std::cout << "before reset: " << memUsage() << std::endl;
strong.reset();
std::cout << "after strong reset: " << memUsage() << std::endl;
weak.reset();
std::cout << "after weak reset: " << memUsage() << std::endl;
return 0;
}
Вывод в консоль при использовании конструктора std: shared_ptr:
initial: 5884
before reset: 71424
~Big
after strong reset: 5884
after weak reset: 5884
Вывод в консоль при использовании std: make_shared:
initial: 5888
before reset: 71428
~Big
after strong reset: 71428
after weak reset: 5888
Бонус
А всё же, возможна ли утечка памяти в результате выполнения кода
auto bar = std::shared_ptr(new Bar);
?
Что произойдёт, если аллокация Bar завершится успешно, а вот на control block памяти уже не хватит?
А что произойдёт, если был вызван конструктор с custom deleter?
Раздел [util.smartptr.shared.const] Стандарта гарантирует, что при возникновении исключения внутри конструктора std: shared_ptr:
— для конструктора без custom deleter переданный указатель будет удалён с помощью delete или delete[];
— для конструктора с custom deleter переданный указатель будет удалён с помощью этого самого deleter.
Отсутствие утечки гарантировано Стандартом.
В результате беглого чтения реализаций в трёх компиляторах (Apple clang version 11.0.3, GCC 9.3.0, MSVC 2019 16.6.2) я могу подтвердить, что всё так и есть.
Вывод
В с++11 и с++14 вред от применения std: make_shared мог быть сбалансирован единственной его полезной функцией.
Начиная же с c++17, арифметика совсем не в пользу std: make_shared.
Аналогичная ситуация и с std: allocate_shared.
Многое из сказанного справедливо также и для std: make_unique, но от него меньше вреда.