Как weak_ptr может помешать полному освобождению памяти управляемого объекта

Добавлено 29 октября 2021 в 04:29
Как weak_ptr может помешать полному освобождению памяти управляемого объекта

Содержание

Когда я работал над справочной статьей об умных указателях, я столкнулся с довольно интересной проблемой. Похоже, что в некоторых случаях память, выделенная для объекта, управляемого shared_ptr, может не быть освобождена до тех пор, пока все слабые указатели (weak_ptr) также не «умрут».

Такой случай был для меня неожиданностью, потому что я думал, что в момент уничтожения последнего shared_ptr память освобождается.

Давайте углубимся в это дело. Это может быть интересно, поскольку мы узнаем, как указатели совместного (shared) владения и слабые (weak) указатели взаимодействуют друг с другом.

Сценарий использования

Хорошо, так в чем же проблема?

Для начала нам нужно рассмотреть элементы сценария использования:

  • управляемый объект, допустим, он большой (как сообщает sizeof);
    • объект может содержать другие указатели/контейнеры, которые могут выделять собственные фрагменты памяти, поэтому они не влияют на конечный размер объекта (за исключением размера указателей). Например, std::vector использует выделение памяти для сохраненных элементов;
  • shared_ptr (один или несколько) – они указывают на рассмотренный выше объект (ресурс);
  • make_shared – используется для создания общего указателя;
  • weak_ptr;
  • блок управления указателей совместного владения и слабых указателей.

Код довольно прост:

Указатели совместного владения на наш большой объект выходят из области видимости. Счетчик ссылок достигает 0, и объект может быть уничтожен. Но есть еще один слабый указатель, который переживает указателей совместного владения.

weak_ptr<MyLargeType> weakPtr;
{
	auto sharedPtr = make_shared<MyLargeType>();
	weakPtr = sharedPtr;
	// ...
}
cout << "scope end...\n";

В приведенном выше коде у нас есть две области видимости: внутренняя – где используется указатель совместного владения, и внешняя – со слабым указателем (обратите внимание, что этот слабый указатель содержит только «слабую» ссылку, он не использует lock() для создания сильной ссылки).

Когда указатель совместного владения выходит за пределы внутреннего блока, он должен уничтожить управляемый объект… верно?

Это важно: когда уходит последний указатель совместного владения, это уничтожает объект в смысле вызова деструктора MyLargeType (это также освобождает память для членов, которые выделяют свои отдельные фрагменты памяти, такие как векторы, строки и т.д.) ... но как насчет памяти, выделенной для объекта? Можем ли мы ее так же освободить?

Чтобы ответить на этот вопрос, давайте рассмотрим второй пример:

weak_ptr<MyLargeType> weakPtr;
{
	shared_ptr<MyLargeType> sharedPtr(new MyLargeType());
	weakPtr = sharedPtr;
	// ...
}
cout << "scope end...\n";

Практически такой же код… не так ли? Разница только в подходе к созданию указателя совместного владения: здесь мы используем явный оператор new.

Давайте посмотрим на результат, когда запустим оба этих примера.

Чтобы получать полезные сообщения, мне нужно было переопределить глобальные операторы new и delete, а также сообщить, когда вызывается деструктор моего тестового класса.

void* operator new(size_t count) 
{
	cout << "allocating " << count << " bytes\n";
	return malloc(count);
}

void operator delete(void* ptr) noexcept 
{
	cout << "global op delete called\n";
	free(ptr);
}

struct MyLargeType 
{
	~MyLargeType() { cout << "destructor MyLargeType\n"; }

private: 
	int arr[100]; // довольно большой!!!!!!
};

Хорошо… теперь посмотрим на результат:

Для make_shared:

allocating 416 bytes
destructor MyLargeType
scope end...
global op delete called

и для случая явного new:

allocating 400 bytes
allocating 24 bytes
destructor MyLargeType
global op delete called
scope end...
global op delete called

Что здесь происходит?

Первое важное наблюдение заключается в том, что, как вы, возможно, уже знаете, make_shared выполняет только одно выделение памяти. С явным new у нас есть два отдельных выделения.

Итак, нам нужно место для двух вещей: объекта и… блока управления.

Блок управления зависит от реализации, но он содержит указатель на объект, а также счетчик ссылок. Плюс кое-что еще (например, пользовательское средство удаления, аллокатор (распределитель памяти) и т.д.).

Когда мы используем явный new, у нас есть два отдельных блока памяти. Поэтому, когда последний указатель совместного владения (shared) исчезнет, ​​мы можем уничтожить объект, а также освободить память.

Итак, мы видим результат:

destructor MyLargeType
global op delete called

И деструктор, и free() вызываются до окончания области видимости.

Однако когда указатели совместного владения создаются с помощью make_shared(), управляемый объект находится в том же блоке памяти, что и остальные детали реализации.

Вот изображение с этой идеей:

Распределение памяти при разных способах создания shared_ptr
Рисунок 1 – Распределение памяти при разных способах создания shared_ptr

Дело в том, что когда вы создаете слабый указатель, то внутри блока управления обычно увеличивается значение «счетчика слабых ссылок». Слабые указатели и указатели совместного владения нуждаются в этом механизме, чтобы они могли ответить на вопрос «мертв указатель, или еще нет» (или для вызова метода expire()).

Другими словами, мы не можем удалить блок управления, если еще есть слабый указатель (в то время как все указатели совместного владения уже мертвы). Поэтому, если управляемый объект размещен в том же фрагменте памяти, мы не можем освободить память и для него – мы не можем освободить только часть блока памяти (по крайней мере, не так легко).

Ниже вы можете найти код из реализации MSVC, этот код вызывается из деструктора shared_ptr (когда он создается из make_shared):

~shared_ptr() _NOEXCEPT
{   // освобождаем ресурс
	this->_Decref();
}

void _Decref()
{   // уменьшаем счетчик использования
	if (_MT_DECR(_Uses) == 0)
	{    // уничтожаем управляемый ресурс,
         // уменьшаем счетчик слабых ссылок
		_Destroy();
		_Decwref();
	}
}

void _Decwref()
{    // уменьшаем счетчик слабых ссылок
	if (_MT_DECR(_Weaks) == 0)
	{
		_Delete_this();
	}
}

Как вы видите, существует разделение на уничтожение объекта (вызывающее только деструктор) и на _Delete_this() (происходит только тогда, когда счетчик слабых ссылок равен нулю).

Вот ссылка, если вы хотите поэкспериментировать с кодом: пример на Coliru.

Не бойтесь!

История выделения и освобождения памяти интересна ... но так ли это на нас влияет?

Возможно, немного.

Вы не должны прекращать использовать make_shared только по этой причине! :)

Дело в том, что это довольно редкая ситуация.

Тем не менее, хорошо бы знать об этом поведении и помнить об этом при реализации некоторых сложных систем, которые полагаются на указатели совместного владения и слабые указатели.

Замечания

После того, как я разобрался в этом случае, я также понял, что немного опаздываю с объяснением – другие уже сделали это :) Тем не менее, я хотел бы отметить некоторые вещи.

Итак, тут также описана эта проблема:

  • «Effective Modern C++», пункт 21 – «Предпочитайте std::make_unique и std::make_shared прямому использованию new».

Из «Effective Modern C++», стр. 144:

Пока указатели std::weak_ptr ссылаются на блок управления (т.е. счетчик слабых ссылок больше нуля), этот блок управления должен продолжать существовать. И пока существует блок управления, содержащая его память должна оставаться выделенной. Таким образом, память, выделенная функцией создания std::shared_ptr, не может быть освобождена до тех пор, пока последний std::shared_ptr и последний, относящийся к нем std::weak_ptr не будут уничтожены.

Резюме

Вся статья была интересным исследованием!

Иногда я ловлю себя на том, что трачу слишком много времени на вещи, которые, возможно, не особо важны. Тем не менее, они интересны. Здорово, что я могу поделиться этим :)

Суть всего исследования в том, что реализация указателей совместного владения и слабых указателей довольно сложна. Когда блок управления размещается в том же фрагменте памяти, что и управляемый объект, необходимо проявлять особую осторожность, когда мы хотим освободить выделенную память.

Помните, что такое поведение shared_ptr/make_shared не является серьезным «недостатком», и вы всё равно должны их использовать! Просто имейте в виду, что если у вас есть weak_ptr, и вы действительно заботитесь о памяти, то нужно проявлять особую осторожность.

Теги

C++ / Cppstd::make_sharedstd::shared_ptrstd::weak_ptrВыделение памятиУмные указатели

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.