Pull to refresh

Боремся с утечками памяти (C++ CRT)

Reading time6 min
Views61K
Утечка памяти — довольно серьезная и опасная проблема. Быть может, пользователь и не заметит однократной утечки каких-нибудь 32Кб памяти (а ведь это целые 5% от 640Кб, которых «хватит всем»), но постоянно теряя сложные иерархические структуры или массивы размером больше INT_MAX (которые мы так любим создавать на 64-битной архитектуре) мы обречем его на страдания, а наш продукт на провал.

Не допускать ситуации вроде бы и не трудно — воспользуемся правилом «класть на место всё что взяли», но на практике это сильно осложняется человеческим фактором (банальная невнимательность), хитростью архитектуры и нелинейным порядком выполнения операторов, например, из-за применения исключений.

А можно было бы просто «отдаться» автоматическому сборщику мусора, ценой потери производительности (и это не обязательно Managed C++, для Native C++ / C есть библиотеки сборки мусора, вот, например).

Но мы поговорим о ситуации когда уже «всё плохо».

Тогда задача сводится к обнаружению и исправлению возможных утечек — что касается исправления, тут обычно всё просто (delete или delete[]). А вот как обнаружить утечку? Гугл с радостью подскажет ответы, которые обычно сводятся к тому, что:
  • нужно использовать сторонние анализаторы утечек
  • придется изобретать велосипеды и самокаты
  • неплохо бы написать собственный менеджер памяти

Но можно и проще, средствами Debug CRT.

Шаг 1. Включение учета утечек


Для этого нужно подключить хедер Debug CRT и включить использование Debug Heap Alloc Map:
#ifdef _DEBUG
#include <crtdbg.h>
#define _CRTDBG_MAP_ALLOC
#endif


* This source code was highlighted with Source Code Highlighter.

Теперь при выделении памяти через new или malloc() данные оборачиваются в следующую структуру (но на самом деле я чуть-чуть лукавлю, поле отвечающее за data не соответствует синтаксису struct и сама «структура» определена где-то внутри CRT и её описание программисту не доступно):

typedef struct _CrtMemBlockHeader
{
  struct _CrtMemBlockHeader * pBlockHeaderNext;
  struct _CrtMemBlockHeader * pBlockHeaderPrev;
  char* szFileName;
  int  nLine;
  size_t nDataSize;
  int nBlackUse;
  long lRequest;
  unsigned char gap[nNoMansLandSize];  
  unsigned char data[nDataSize];
  unsigned char anotherGap[nNoMansLandSize];
} _CrtMemBlockHeader;


* This source code was highlighted with Source Code Highlighter.

Она содержит информацию об имени файла szFileName и строке nLine, где произошло выделение памяти, объем запрошенной памяти nDataSize, и, собственно сами данные data, обернутые в так называемую No Mans Land область. Сами BlockHeader'ы организованы в двусвязный список, что позволяет легко перечислить их, и, соответственно, выявить все операции выделения памяти, для которых не было соответствующей операции освобождения.

Шаг 2. Перечисление утечек


Нужна функция, которая пробежится по списку CrtMemBlockHeader'ов и выдаст нам информацию о проблемных местах:

_CrtDumpMemoryLeaks();

Тогда в окне Debug Output мы увидим следующую информацию:
Detected memory leaks!
Dumping objects ->
{163} normal block at 0x00128788, 4 bytes long.
Data: <  > 00 00 00 00
{162} normal block at 0x00128748, 4 bytes long.
Data: <  > 00 00 00 00
Object dump complete.


* This source code was highlighted with Source Code Highlighter.

И это почти круто, но этот результат еще не пригоден к использованию по нескольким причинам:
  • Он не содержит информации о файле и строке, где была выделена память (а ведь в структуре такая информация есть!)
  • Очень хотелось бы вывести его в какой-нибудь лог-файл (хоть какая автоматизация)
  • Он содержит лишюю информацию, то есть не только память которая уже «утекла»...

… а еще ту, которая просто не успела «вернуться» из глобальных объектов. И может быть глобальные объекты — это плохо, но сейчас свыкнемся с мыслью, что они есть, а значит из вывода _CrtDumpMemoryLeaks() нужно как-то их удалить. И решается это следующим приемом:

int _tmain(int argc, _TCHAR* argv[])
{
  _CrtMemState _ms;
  _CrtMemCheckpoint(&_ms);
 
  // some logic goes here...
  
   _CrtMemDumpAllObjectsSince(&_ms);
  return 0;
}

* This source code was highlighted with Source Code Highlighter.

Мы записываем в специальную структуру начальное (текущее на момент входа в main) состояние памяти (_CrtMemCheckpoint), а перед завершением работы приложения выводим все оставшиеся объекты в памяти (_CrtMemDumpAllObjectsSince), созданные после момента _ms — они-то и есть «утечки». Теперь информация корректна, позаботимся об её удобстве.

Шаг 3. Представление результатов


Перенаправить вывод очень легко, здесь нам помогут функции _CrtSetReportMode и _CrtSetReportFile.

_CrtSetReportMode( _CRT_WARN, _CRTDBG_MODE_FILE );
_CrtSetReportFile( _CRT_WARN, _CRTDBG_FILE_STDOUT );


* This source code was highlighted with Source Code Highlighter.

Теперь вывод всех предупреждений (а таковым является любой вывод _CrtMemDumpAllObjectsSince) отправится прямиком на stdout. Вторым параметром функции _CrtSetReportFile можно поставить и реальный хендл файла.

Почему не выводятся имена файлов и строки, где произошло выделение памяти? Так сложилось, что по версию Microsoft Visual C++ 6.0 за эту информацию отвечала следующее переопределение функции new в хедере crtdbg.h:

#ifdef _CRTDBG_MAP_ALLOC
  inline void* __cdecl operator new(unsigned int s)
     { return ::operator new(s, _NORMAL_BLOCK, __FILE__, __LINE__); }
  #endif /* _CRTDBG_MAP_ALLOC */

* This source code was highlighted with Source Code Highlighter.

И, нетрудно догадаться, это не давало желаемого результата, __FILE__:__LINE__ всегда разворачивались в «crtdbg.h file line 512». А потом ребята из Microsoft вообще убрали эту «фичу», отдав её на откуп программисту. Ну и не страшно, ведь добиться этой функциональности можно одним определением:
#define new new( _NORMAL_BLOCK, __FILE__, __LINE__)

* This source code was highlighted with Source Code Highlighter.

Котрое весьма желательно вынести в какой-нибудь общий заголовочный файл (подключаемый обязательно после crtdbg.h). Проблемы возникнут, если new уже был переопределен. Хотя, как видится мне, какое-либо разумное переопределение new не будет использовать CRT (иначе можно было бы использовать технику hook), и схема, в этом случае, вообще будет не применима, ну и ладно.

В общем и целом теперь получили что хотели: вот пример вывода, но, думаю, и так очевидно, что там должно быть.

Час расплаты


Конечно, на организацию и поддержку CRT Internals структур требуется время и дополнительная память. Насколько же много?

UPD: Все что ниже — справедливо только для Win32 (тестировалось на Vista SP1).

Создаем 10 миллионов int с помощью new (40Mb памяти теоретически):
Debug CRT ~500Mb 3 секуды
Release ~160Mb 1 секунда

Цифра в ~160Mb для релизной сборки может немнго удивить. Но это нормально — new выделяет память через функцию ОС HeapAlloc, которая выравнивает данные по кратным 16 адресам (для Win32). Выделяя память на один символ мы получаем еще 15 байт, с которыми даже можно сделать (но уж точно не нужно делать) что-нибудь нехорошее. Для дебага очень предсказуемый результат — добавим еще sizeof(_CrtMemBlockHeader) помноженный на 10 миллионов и получим, как раз, 500 мегабайт.

Интересным эмпирическим результатом оказалось, что в релизе new int работает где-то на 10% медленнее, чем HeapAlloc на 4 байта, едва ли отличимо по скорости от new int() (инициализация значением по умолчанию, то есть нулем), и быстрее на 5-10% чем HeapAlloc с флагом HEAP_ZERO_MEMORY.

Ну а теперь 128 тысяч int[256] через new int[256] (128Mb памяти теоретически):
Debug CRT ~136Mb 172 мс
Release ~128.5Mb 60 мс

Результаты предсказуемые и вполне удовлетворительные. Отношение скорости 1:3 подтвердилось и на данных другого размера, в том числе при смешивании различных данных и частичном освобождении памяти. Но и без операций с динамической памятью Debug код работает в несколько раз медленнее Release!

Вывод


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

Ну вот, пожалуй и всё. Разве что исходничек для воссоздания целостной картины.

UPD: Метод не собирается конкурировать с внешними анализаторами, т.к. цели несколько разные, но упоминания о стоящих тулсах очень приветствуются (только, пожалуйста, без повторов).
Tags:
Hubs:
+31
Comments45

Articles