Error potential leak of memory pointed to by

Каждый программист, работающий с языком С++, должен уметь находить утечки памяти. Язык С++ - сложный язык, делать ошибки легко, а находить их бывает муторно. Осо...

Каждый программист, работающий с языком С++, должен уметь находить утечки памяти. Язык С++ — сложный язык, делать ошибки легко, а находить их бывает муторно. Особенно это касается утечек памяти. Ситуация с отловом утечек памяти только усугубляется, если в коде С++ используется библиотека Qt.

Эта статья посвящена разным инструментам, которые можно с той или иной степенью успешности применять для отлова утечек памяти в С++/Qt приложениях (desktop). Инструменты будут рассмотрены в связке с IDE Visual Studio 2019. В статье будут рассмотрены не все возможные инструменты, а лишь наиболее популярные и эффективные.

Наша команда давно и пристально изучает подобные инструменты и использует их в своей работе. Объем кода, на котором есть возможность проверить подобные инструменты, составляет около 1.5 миллиона строк. Опираясь на большой практический опыт, мы расскажем о плюсах и минусах разных инструментов, расскажем, что они способны найти, а что не по зубам, расскажем о неочевидных нюансах и, главное, составим сводную сравнительную таблицу по реальному примеру. Мы постараемся максимально быстро и просто ввести в курс дела (показать быстрый старт), потому даже если ты, читатель, никогда не занимался поиском утечек памяти, эта статья поможет за пару часов разобраться и найти свою первую утечку. Поехали!

В чем проблема?

Утечка памяти – ситуация, когда память была выделена (например, оператором new) и ошибочно не была удалена соответствующим оператором/функцией удаления (например, delete).

Пример 1.

int* array = nullptr;
for (int i = 0; i < 5; i++)
{
	array = new int[10];
}
delete[] array;

Здесь налицо утечка при выделении памяти для первых 4 массивов. Утекает 160 байт. Последний массив удаляется корректно. Итак, утечка строго в одной строке:

array = new int[10];

Пример 2.

class Test
{
public:
	Test()
	{
		a = new int[100];
		b = new int[300];
	}
	~Test()
	{
		delete[] a;
		delete[] b;
	}

private:
	int* a;
	int* b;
};

int main()
{
	Test* test = new Test;

	return 0;
}

Здесь утечек уже больше: не удаляется память для a (400 байт), для b (1200 байт) и для test (16 байт для x64). Впрочем, удаление a и b в коде предусмотрено, но его не происходит из-за отсутствия вызова деструктора Test. Таким образом, утечек три, но ошибка, приводящая к этим утечкам, всего одна, и она порождается строкой

Test* test = new Test;

При этом в коде класса Test ошибок нет.

Пример 3.

Пусть есть класс Qt, примерно такой:

class InfoRectangle : public QLabel
{
	Q_OBJECT

public:
	InfoRectangle(QWidget* parent = nullptr);

private slots:
	void setInfoTextDelayed();

private:
	QTimer* _textSetTimer;
};
InfoRectangle::InfoRectangle(QWidget* parent)
	: QLabel(parent)
{
	_textSetTimer = new QTimer(this);
	_textSetTimer->setInterval(50);
	connect(_textSetTimer, &QTimer::timeout, this, &InfoRectangle::setInfoTextDelayed);
}

void InfoRectangle::setInfoTextDelayed()
{
	// do anything
	setVisible(true);
}

Пусть также где-то в коде затесалось выделение памяти:

InfoRectangle* rectangle = new InfoRectangle();

Будет ли являться это утечкой, если явно не вызван delete? Это зависит от того, включен ли объект в иерархию объектов Qt. Если объект включён одним из следующих примерных вызовов, то нет, не утечка:

mnuLayout->addWidget(rectangle);
rectangle->setParent(this);

В остальных же случаях – утечка. Причем если мы будем считать точное количество утечек в этом примере, то можем наткнуться на неожиданный вывод: утечек больше, чем можно сначала предположить. Очевидная утечка – выделение памяти для InfoRectangle. Побочная утечка – выделение памяти для QTimer, несмотря на включение объекта _textSetTimer в иерархию объектов Qt. А вот утечка, которая совсем не очевидна – вызов функции connect.

Дело в том, что в ее реализации вызовом new всё же создается некий объект:

template <typename Func1, typename Func2>
    static inline QMetaObject::Connection connect(
const typename QtPrivate::FunctionPointer<Func1>::Object *sender, Func1 signal,
                const typename QtPrivate::FunctionPointer<Func2>::Object *receiver, Func2 slot,
                Qt::ConnectionType type = Qt::AutoConnection)
    {
        typedef QtPrivate::FunctionPointer<Func1> SignalType;
        typedef QtPrivate::FunctionPointer<Func2> SlotType;

        const int *types = nullptr;
        if (type == Qt::QueuedConnection || type == Qt::BlockingQueuedConnection)
            types = QtPrivate::ConnectionTypes<typename SignalType::Arguments>::types();

        return connectImpl(sender, reinterpret_cast<void **>(&signal),
                           receiver, reinterpret_cast<void **>(&slot),
                           new QtPrivate::QSlotObject<Func2, typename QtPrivate::List_Left<
typename SignalType::Arguments, SlotType::ArgumentCount>::Value,
                                          	typename SignalType::ReturnType>(slot),
                            type, types, &SignalType::Object::staticMetaObject);
    } 

Таким образом, утечки памяти могут обнаруживаться в весьма неожиданных местах. А могут и вовсе не обнаруживаться, в зависимости от используемого инструментария. К примеру, если инструментарий не способен проникнуть внутрь кода Qt, то он не сможет ни обнаружить утечку в connect, ни разобраться с иерархиями Qt, а последнее, в свою очередь, уже чревато ложными срабатываниями.

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

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

Для этого мы взяли реальный пример – конкретную ревизию репозитория, для которой было точно известно, что утечки есть, и начали подробно с ней работать. Мы взяли один типичный сценарий работы пользователя с нашим приложением и начали на нём запускать все возможные динамические инструменты отлова утечек. Запускали многократно с разными настройками, детально анализируя полученные отчеты об ошибках. В итоге сформировали итоговый список ошибок в коде, приведших к утечкам памяти. Этот список назвали эталонным и посчитали, что других ошибок (ненайденных) нет. И в дальнейшем результаты, полученные каждой отдельной утилитой поиска утечек, сравнивали с эталонным списком ошибок.

Проект

Размер кода

Сценарий работы пользователя

Ревизия репозитория

Кол-во ошибок в эталоне

Суммарный объем утекающей памяти

Конкретный проект

1.5 млн строк

Конкретный сценарий: запускаем ПО, жмем на кнопку 1, потом на кнопку 2, ждем завершения вычислений, закрываем ПО

конкретная

7

253 кБ

Таблица 1. Эталон поиска утечек памяти.

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

Intel Inspector

Intel Inspector – графическая утилита, удобно встраиваемая в Visual Studio и позволяющая в виде кликабельного списка выдавать места в коде с предполагаемыми утечками оперативной памяти проверяемого приложения и некоторыми другими проблемами памяти. В сценарии отлова утечек памяти Intel Inspector использует динамический анализ, а это значит, что если в процессе работы приложения код с утечками памяти не будет вызван, то и проблем в нем не будет найдено.

Установка

Intel Inspector входит в состав пакета Intel Parallel Studio 2019, при этом есть возможность установить только сам Intel Inspector, убрав галочки с остальных компонентов дистрибутива при установке. Visual Studio 2019 должна быть закрыта в момент установки Intel Parallel Studio. После установки, Intel Inspector будет автоматически встроен в Visual Studio и должен появиться на панели инструментов (рис. 1).

Рис. 1. Начало работы с Intel Inspector`ом

Рис. 1. Начало работы с Intel Inspector`ом

Если значок Intel Inspector’а не виден на панели инструментов, нужно щёлкнуть правой кнопкой мыши где-нибудь на этой панели инструментов и поставить галочку «Intel Inspector».

Запуск

При нажатии на кнопку-значок появится вкладка Intel Inspector с выбором глубины анализа. Выбираем первый пункт «Detect Leaks» и включаем все галочки, соответствующие всем видам анализа (рис. 2). Если какие-то галочки пропустить, то, к сожалению, есть риск, что не все утечки будут найдены.

Рис. 2. Вкладка Intel Inspector`а для его настройки и запуска

Рис. 2. Вкладка Intel Inspector`а для его настройки и запуска

Далее нажимаем кнопку «Start», через некоторое время откроется приложение. В нем нужно запустить тот или иной сценарий работы, а лучше все сразу (то есть, как следует «погонять» приложение), затем закрыть. Чем больше на разных параметрах, в разных режимах и в разных сценариях проработает приложение, тем больше утечек памяти будет найдено. И это общий принцип для всех механизмов поиска утечек, использующих динамический анализ. Как мы уточнили ранее, в целях сравнения мы запускали только эталонный сценарий тестирования (см. табл. 1). Итак, после закрытия приложения Intel Inspector слегка задумывается и в итоге выдаёт отчёт следующего вида (рис. 3):

Рис. 3. Пример результатов анализа ПО на утечки памяти с помощью Intel Inspector.

Рис. 3. Пример результатов анализа ПО на утечки памяти с помощью Intel Inspector.

В отчете выдаются кликабельный и сортируемый список утечек, размеры утечек, места в коде с утечками, call-stack и многое другое.  Короче, форма выдачи результатов весьма и весьма на уровне. Все очень быстро понимается и усваивается. Все это – внутри IDE!

Это будет работать, если есть отладочная информация. То есть debug работать будет, а release нет. В С++-приложениях часто бывает так, что работа в режиме debug намного медленнее, чем в release (мы фиксировали разницу в скорости  до 20 раз), и пользоваться debug’ом очень некомфортно. Однако на этот случай есть лайфхак – собрать версию release (быструю, со всеми ключами оптимизации), дополнительно включив в нее отладочную  информацию. Это позволяет Intel Inspector’у подсветить строки в исходном коде, где он предполагает наличие утечек. О том, как включить в release отладочную информацию, написано здесь.

Результаты

Мы провели сравнение скоростных характеристик работы приложения в разных режимах работы: с Intel Inspector (будем называть его Инспектор) и без него, в debug и release. Тестирование проводилось на эталонном примере (см. табл 1).

Конфигурация

Среднее время теста, с

Замедление
работы, что привносит Инспектор, раз

Без Инспектора

С Инспектором

Release c отладочной информацией

10

70

7

Debug

101

973

9,6

Таблица 2. Время тестирования с учётом работы Intel Inspector`а

По таблице можно видеть, что проверка на утечки памяти требует весьма существенного времени. Особенно этот факт начинает играть существенными красками в свете непрерывной интеграции. То есть, если, к примеру, тот сценарий, на котором мы проверяли, включен в состав тестов регрессионного тестирования, то он в них выполняется за 10 секунд. Если же мы захотим на этом же сценарии прогонять поиск утечек памяти (в debug), то это займет примерно в 100 раз больше времени. Прогонять каждую ночь (а тем более, по каждому коммиту) тесты становится невозможно.

Самое главное – что можно сказать по качеству найденных утечек? Действительно ли они являются утечками? Нет ли утечек, не замеченных Intel Inspector`ом?

Конфигурация

Кол-во ошибок в эталоне: n

Найдено утечек

Пропущено ошибок из эталона: r

Точность: (n-r)/n

Избыточность: N/n

Всего: N

Верных

Ложных

Release c отладочной информацией

7

192

168

24

0

1 (100%)

27 раз

Debug

7

129

107

22

0

1 (100%)

18 раз

Таблица 3. Результаты работы Intel Inspector

Да, Intel Inspector действительно способен найти реальные утечки памяти. Это долго и мучительно, но он их находит. Пропусков утечек памяти мы не зафиксировали. При этом в итоговом отчете, который формирует Intel Inspector, бывает так, что на каждую строчку кода, где реально совершена ошибка, выводится куча строчек, которые «породились» этой ошибкой (как в примерах 2 и 3, см. выше).

Если ликвидировать все такие реальные ошибки, то Intel Inspector все равно будет показывать еще немало утечек, и все они – ложные. Более того, по таблице видно, что этих ложных срабатываний в release больше, чем в debug. И в этом случае, судя по всему, сработала оптимизация при компиляции – она скрыла от Инспектора некоторые детали, и Инспектор запутался.

Приведем несколько произвольных примеров ложных срабатываний.

Пример 1. Утечки в системных dll.

Intel Inspector может обнаружить вот такие вот странные утечки в подгружаемых системных dll, с таким интересным стеком. К проверяемому нами коду такие утечки вообще отношения не имеют, даже если по факту там утечки и есть.

Рис. 4. Утечки в системных dll.

Рис. 4. Утечки в системных dll.

Пример 2. aligned_malloc.

m_pVitData = (VITDEC_DATA*)_aligned_malloc(sizeof(VITDEC_DATA), 16);
m_pDcsnBuf = (byte*)_aligned_malloc(64 * (VITM6_BUF_LEN + VITM6_MAX_WND_LEN), 16);
...
_aligned_free(m_pDcsnBuf);
_aligned_free(m_pVitData);

К счастью, подобная «утечка» находится только в release, а в debug нет.

Пример 3. Pragma.

#pragma omp parallel for schedule(dynamic)
for (int portion = 0; portion < portionsToProcess; ++portion)
{
	…
}

Утечка показывается именно в строке с директивой #pragma!

Возможно, какими-то настройками (внутри Intel Inspector, внутри VS, переменные окружения и т.д.) можно победить этот вал ложных утечек, но из коробки – точно нет. Возможно также, что на маленьких и простых приложениях (<50000 строк кода) таких проблем с Intel Inspector не будет. На серьезных же приложениях – точно будут, к гадалке не ходи.

Вывод

Intel Inspector – штука довольно удобная и полезная, способная найти все утечки (если прогнать все сценарии), выдающая относительно немного ложных срабатываний. Работа в конфигурации release с отладочной информацией довольно быстра, но выдает больше ложных срабатываний (а значит, больше ручной работы по их проверке), чем debug. При этом работа в конфигурации debug фантастически медленна.

Что касается стабильности работы, то Intel Inspector здесь не может показать выдающиеся результаты. Иногда в процессе тестирования бывают падения, иногда зависания, причем на ровном месте. Иногда нам попадались такие важные для нас проекты и сценарии работы пользователя, когда  вообще не получалось даже «завести» Intel Inspector, не то, что «доехать» на нём до получения результатов.

Visual Leak Detector

Visual Leak Detector (далее VLD) – маленькая библиотека, включаемая в исходный код каждого проекта и выводящая в окно Output (IDE Visual Studio 2019) отчёт по утечкам памяти.

Установка

  1. Убедиться, что Visual Studio не запущена.

  2. Перед установкой VLD нужно сначала удалить старый VLD, если такой есть, через Панель управления, т.к. предлагаемая автоматическая деинсталляция старой версии не всегда работает.

  3. Далее установить последний VLD (на момент написания статьи vld-2.5.1-setup.exe) по умолчанию, оставив при установке все галочки включёнными (на добавление в Path и встраивание в Visual Studio). Установщик можно скачать отсюда.

  4. На момент написания статьи в дистрибутиве VLD нет нужных dll-файлов для работы с Visual Studio 2019, потому необходимо скопировать dbghelp.dll из папки C:Program Files (x86)Microsoft Visual Studio2019EnterpriseCommon7IDEExtensionsTestPlatformExtensionsCppx64 в папку C:Program Files (x86)Visual Leak DetectorbinWin64.

  5. Нужно создать заголовочный файл примерно со следующим содержанием:

    #pragma once
    
    //#define LEAKS_DETECTION
    
    #ifdef LEAKS_DETECTION
    #include <vld.h>
    #endif

    Как видно, пока что это пустой файл, проверка на утечки памяти в нем выключена.

  6. В любой файл реализации (сpp) нужно включить этот новый заголовочник. Это нужно сделать только для одного файла внутри проекта, и для каждого проекта в solution.

Запуск

Достаточно раскомментировать в заголовочном файле строчку

#define LEAKS_DETECTION

и собрать solution. После этого можно запускать (F5) приложение и прогонять разные сценарии, где могут быть утечки памяти. Запускать можно только в конфигурации debug. Release c отладочной информацией работать не будет.

После закрытия проверяемого приложения VLD выведет отчет в окно Output. Отчет содержит список утечек, кликабельный call-stack по каждой утечке, размеры утечек.

Пример того, что выводит VLD

---------- Block 652047 at 0x0000000027760070: 8787200 bytes ----------
  Leak Hash: 0x02B5C300, Count: 1, Total 8787200 bytes
  Call Stack (TID 30996):
    ucrtbased.dll!malloc()
    d:agent_work63ssrcvctoolscrtvcstartupsrcheapnew_array.cpp (29): SniperCore.dll!operator new[]()
    D:SOURCESAP_Gitsap_win64corealgfbgddecS2Ldfg.cpp (445): SniperCore.dll!CS2Ldfg::CreateLLRTbls() + 0xD bytes
    D:SOURCESAP_Gitsap_win64corealgfbgddecS2Ldfg.cpp (217): SniperCore.dll!CS2Ldfg::SetModeEB()
    D:SOURCESAP_Gitsap_win64corealgfbgddecS2Ldfg.cpp (1447): SniperCore.dll!CS2Ldfg::Set() + 0xA bytes
    D:SOURCESAP_Gitsap_win64corealgfbgddecddec.cpp (509): SniperCore.dll!DFBase::instanceS2Dec()
    D:SOURCESAP_Gitsap_win64corealgfbgddecddec.cpp (58): SniperCore.dll!DFBase::DFBase() + 0xF bytes
    D:SOURCESAP_Gitsap_win64corealgfbgddecddec.cpp (514): SniperCore.dll!DgbS5FecAnlzr::DgbS5FecAnlzr() + 0xA bytes
    D:SOURCESAP_Gitsap_win64corealgfbgfbganalyser.cpp (45): SniperCore.dll!TechnicalLayer::FBGAnalyser::FBGAnalyser() + 0x21 bytes
    D:SOURCESAP_Gitsap_win64coreenginehandlersfbganalysishandler.cpp (218): SniperCore.dll!TechnicalLayer::FBGAnalysisHandler::init() + 0x2A bytes
    D:SOURCESAP_Gitsap_win64coreenginehandlersfbganalysishandler.cpp (81): SniperCore.dll!TechnicalLayer::FBGAnalysisHandler::enqueueRequest()
    D:SOURCESAP_Gitsap_win64coreenginethreadedhandler2.cpp (57): SniperCore.dll!TotalCore::ThreadedHandler2::run()
    Qt5Cored.dll!QTextStream::realNumberPrecision() + 0x89E8E bytes
    kernel32.dll!BaseThreadInitThunk() + 0xD bytes
    ntdll.dll!RtlUserThreadStart() + 0x1D bytes
  Data:
    00 00 00 00    01 01 01 01    01 01 01 02    02 02 02 02     ........ ........
    02 02 03 03    03 03 03 03    03 04 04 04    04 04 04 04     ........ ........
    05 05 05 05    05 05 05 05    06 06 06 06    06 06 06 07     ........ ........
    07 07 07 07    07 07 08 08    08 08 08 08    08 09 09 09     ........ ........
    09 09 09 09    0A 0A 0A 0A    0A 0A 0A 0B    0B 0B 0B 0B     ........ ........
    0B 0B 0C 0C    0C 0C 0C 0C    0C 0D 0D 0D    0D 0D 0D 0D     ........ ........
    0E 0E 0E 0E    0E 0E 0E 0E    0F 0F 0F 0F    0F 0F 0F 10     ........ ........
    10 10 10 10    10 10 11 11    11 11 11 11    11 12 12 12     ........ ........
    EE EE EE EE    EF EF EF EF    EF EF EF F0    F0 F0 F0 F0     ........ ........
    F0 F0 F1 F1    F1 F1 F1 F1    F1 F2 F2 F2    F2 F2 F2 F2     ........ ........
    F3 F3 F3 F3    F3 F3 F3 F3    F4 F4 F4 F4    F4 F4 F4 F5     ........ ........
    F5 F5 F5 F5    F5 F5 F6 F6    F6 F6 F6 F6    F6 F7 F7 F7     ........ ........
    F7 F7 F7 F7    F8 F8 F8 F8    F8 F8 F8 F9    F9 F9 F9 F9     ........ ........
    F9 F9 FA FA    FA FA FA FA    FA FB FB FB    FB FB FB FB     ........ ........
    FC FC FC FC    FC FC FC FC    FD FD FD FD    FD FD FD FE     ........ ........
    FE FE FE FE    FE FE FF FF    FF FF FF FF    FF 00 00 00     ........ ........


---------- Block 2430410 at 0x000000002E535B70: 48 bytes ----------
  Leak Hash: 0x7062B343, Count: 1, Total 48 bytes
  Call Stack (TID 26748):
    ucrtbased.dll!malloc()
    d:agent_work63ssrcvctoolscrtvcstartupsrcheapnew_scalar.cpp (35): SniperCore.dll!operator new() + 0xA bytes
    C:Program Files (x86)Microsoft Visual Studio2019EnterpriseVCToolsMSVC14.28.29333includexmemory (78): SniperCore.dll!std::_Default_allocate_traits::_Allocate()
    C:Program Files (x86)Microsoft Visual Studio2019EnterpriseVCToolsMSVC14.28.29333includexmemory (206): SniperCore.dll!std::_Allocate<16,std::_Default_allocate_traits,0>() + 0xA bytes
    C:Program Files (x86)Microsoft Visual Studio2019EnterpriseVCToolsMSVC14.28.29333includexmemory (815): SniperCore.dll!std::allocator<TotalCore::TaskResult *>::allocate()
    C:Program Files (x86)Microsoft Visual Studio2019EnterpriseVCToolsMSVC14.28.29333includevector (744): SniperCore.dll!std::vector<TotalCore::TaskResult *,std::allocator<TotalCore::TaskResult *> >::_Emplace_reallocate<TotalCore::TaskResult * const &>() + 0xF bytes
    C:Program Files (x86)Microsoft Visual Studio2019EnterpriseVCToolsMSVC14.28.29333includevector (708): SniperCore.dll!std::vector<TotalCore::TaskResult *,std::allocator<TotalCore::TaskResult *> >::emplace_back<TotalCore::TaskResult * const &>() + 0x1F bytes
    C:Program Files (x86)Microsoft Visual Studio2019EnterpriseVCToolsMSVC14.28.29333includevector (718): SniperCore.dll!std::vector<TotalCore::TaskResult *,std::allocator<TotalCore::TaskResult *> >::push_back()
    D:SOURCESAP_Gitsap_win64includecoreenginetask.h (119): SniperCore.dll!TotalCore::LongPeriodTask::setTmpResult()
    D:SOURCESAP_Gitsap_win64includecoreenginediscretestephandler.h (95): SniperCore.dll!TotalCore::DiscreteStepHandler::setResult()
    D:SOURCESAP_Gitsap_win64coreenginehandlersprmbdtcthandler.cpp (760): SniperCore.dll!TechnicalLayer::PrmbDtctHandler::setContResult() + 0x1A bytes
    D:SOURCESAP_Gitsap_win64coreenginehandlersprmbdtcthandler.cpp (698): SniperCore.dll!TechnicalLayer::PrmbDtctHandler::processPortion()
    D:SOURCESAP_Gitsap_win64coreenginethreadedhandler2.cpp (109): SniperCore.dll!TotalCore::ThreadedHandler2::tryProcess()
    D:SOURCESAP_Gitsap_win64coreenginethreadedhandler2.cpp (66): SniperCore.dll!TotalCore::ThreadedHandler2::run()
    Qt5Cored.dll!QTextStream::realNumberPrecision() + 0x89E8E bytes
    kernel32.dll!BaseThreadInitThunk() + 0xD bytes
    ntdll.dll!RtlUserThreadStart() + 0x1D bytes
  Data:
    10 03 51 05    00 00 00 00    B0 B4 85 09    00 00 00 00     ..Q..... ........
    60 9D B9 08    00 00 00 00    D0 1B 24 06    00 00 00 00     `....... ..$.....
    30 B5 4F 11    00 00 00 00    CD CD CD CD    CD CD CD CD     0.O..... ........

В конце отчёта присутствует краткий итог в виде:

Visual Leak Detector detected 383 memory leaks (253257876 bytes).
Largest number used: 555564062 bytes.
Total allocations: 2432386151 bytes.
Visual Leak Detector is now exiting.

Или, если утечек нет,

No memory leaks detected.
Visual Leak Detector is now exiting.

Результаты

Мы провели сравнение скоростных характеристик работы приложения  в конфигурации debug в разных режимах работы: с VLD и без него. Как было сказано, в конфигурации release (пусть даже и с отладочной информацией) vld работать не может. В табл. 4 замеры времени выполнения для release приводятся исключительно для сравнения с debug. Тестирование проводилось на эталонном примере (см. табл. 1).

Конфигурация

Среднее время теста, с

Замедление работы, что привносит VLD, раз

Без VLD

С VLD

Debug

101

172

1,7

Release c отладочной информацией

10

Таблица 4. Время тестирования с учётом работы VLD

Что можно сказать по качеству найденных утечек? Действительно ли они являются утечками? Нет ли утечек, не замеченных VLD?

Конфигурация

Кол-во ошибок в эталоне: n

Найдено утечек

Пропущено ошибок из эталона: r

Точность: (n-r)/n

Избыточность: N/n

Всего: N

Верных

Ложных

Debug

7

185

185

0

0

1 (100%)

26 раз

Таблица 5. Результаты работы VLD

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

connect(arrowKeyHandler, &ArrowKeyHandler::upPressed,
			[this] { selectNeighbourSignal(TopSide); }); 

Потом, разбираясь, оказывается, что деструктор класса всё же не вызывается, и утечка в функции connect действительно есть (см. пример 3). Но поначалу этому списку сложно поверить из-за большой избыточности.

Если ликвидировать все такие реальные ошибки, то VLD честно скажет, что утечек нет. И этот факт крайне важен для continuous integration.

Вывод

Visual Leak Detector – штука очень простая и очень полезная, способная найти все утечки (если прогнать все сценарии) и при этом не выдающая ложных срабатываний. Прогон сценариев в VLD довольно медленный, однако, он всё же быстрее, чем в Intel Inspector в конфигурации debug. Плоский, не очень дружественный и «простынообразный» вывод результатов способен запутать своей объемностью и дубликатами, однако со временем и к нему можно привыкнуть и даже использовать в continuous integration.

По стабильности работы больших нареканий нет. Однако всё же было несколько важных для нас проектов и сценариев работы в них, когда vld выдавал исключение и категорически отказывался работать. А жаль, если бы не это обстоятельство, то ценность этой утилиты была бы намного больше.

Снимки оперативной памяти в VS 2019

В IDE Visual Studio 2019 есть собственный встроенный компонент для диагностики проблем – Diagnostic Tools. В его составе есть механизм получения снимков памяти (snapshots). С его помощью можно находить утечки памяти как разницу (дельта) между снимками.  Само собой, чтобы дельта показывала именно утечки, надо делать снимки в определенные, далеко не случайные моменты.

Запуск

Запустите приложение в отладчике (в конфигурации debug или release c отладочной информацией). При запуске  должна по умолчанию появиться панель Diagnostic Tools. Выберите на этой панели вкладку Memory Usage, нажмите кнопку Heap Profiling и дальше делайте снимки кнопкой Take Snapshot.

Чтобы поймать утечку памяти, возникающую в каком-то сценарии работы с приложением, надо получить снимки непосредственно до и после выполнения сценария, а затем взять между ними дельту. Это будет работать только для тех сценариев, для которых точно известно, что они должны освобождать занятые ресурсы после своего завершения. К сожалению, далеко не для всех сценариев это так.

Перед созданием каждого снимка удобно либо временно приостанавливать работу приложения кнопкой Break All при отладке, либо ставить точку останова на нужное место в коде, и она сработает в нужный момент, после чего можно делать снимок.

Рис. 5. Работа со снимками памяти.

Рис. 5. Работа со снимками памяти.

Для просмотра результатов нажмём на прирост памяти между снимками (см. Рис. 5, где стрелочка). В появившейся вкладке в области редактора кода выберем ViewMode -> Stacks View (вместо Types View), и через некоторое время формирования отчёта увидим интерактивное дерево вызовов:

Рис. 6. Работа со снимками памяти, call-stack.

Рис. 6. Работа со снимками памяти, call-stack.

Результаты

Надо честно признать, что работать со снимками памяти при нахождении утечек очень сложно в больших приложениях на Qt: очень большой лог, глубоко завязанный на специфику Qt. Из него можно вычленять крупицы реальных ошибок, но это довольно трудоемко. Взяв наш эталонный сценарий тестирования (см. табл. 1), мы не смогли за пару часов найти ни одной утечки. Зато смогли добиться того, чтобы инструмент самопроизвольно отключался с ошибкой.

Вывод

Для поиска утечек памяти механизмом снимков можно пользоваться только, если никакой другой инструментарий попросту не запускается на вашем тесте (падает, зависает и т.д.). Даже если механизмом снимков удастся найти утечку, скорее всего, это потребует колоссального ручного анализа. Однако для поиска простых утечек (вроде выделения большого куска памяти в маленьком приложении) снимки памяти могут пригодиться.

PVS-Studio

Последняя из рассматриваемых в данной статье утилита, которая может помочь в нахождении утечек памяти, — PVS-Studio. Она делает статический анализ кода, не требующий запуска приложения. Анализ может запускаться и для одного выделенного проекта, и для всех проектов в solution. Также он может запускаться при каждом новом сохранении редактируемого файла исходного кода, «инкрементно», сразу указывая на сомнительные места в новом коде.

Установка

Проблем с установкой нет. В результате установки PVS-Studio оказывается встроенной в Visual Studio 2019, в меню «Extensions».

Запуск

Для запуска всего solution`а вызываем команду Extensions->PVS-Studio->Check. Результат проверки выдается во вкладке «PVS-Studio» и содержит список потенциальных ошибок в коде, распределённых по вкладкам с «критичностью» High, Medium и Low.

Этот объемный результирующий список будет содержать не только утечки памяти, а и все остальные ошибки в коде, что PVS-Studio посчитает ошибками. Чтобы оставить только то, что касается только утечек памяти, нужно фильтровать список по следующим кодам: V599, V680, V689, V701, V772, V773, V1005, V1023 (более подробно см. здесь).

Для фильтрации нужно зайти в настройки Visual Studio в меню Tools -> Options -> PVS-Studio и на вкладке «Detectable Errors (C++)» выставить нужные галочки, убрав остальные (при этом удобно сначала использовать команду «Hide All», а потом уже ставить галочки) – Рис. 8. Также нужно убрать галочки из других групп и вкладки «Detectable Errors (C#)» (выбирая «Hide All» или «Disabled»).

Рис. 8. Фильтрация списка найденных утилитой PVS-Studio ошибок.

Рис. 8. Фильтрация списка найденных утилитой PVS-Studio ошибок.

Чтобы показать все сообщения с выбранными кодами ошибок, нужно убедиться, что в окне PVS-Studio над сообщениями все кнопки High, Medium и Low включены.

Результаты

Итак, для поиска утечек памяти был запущен анализ на проекте, включающем около 1.5 млн строк кода и 2269 файлов кода. Анализ производился на Intel Core i7 4790K. Конфигурация кода (debug или release) значения не имеет, поскольку анализ статический (если более точно, разница есть из-за условной компиляции, но она непринципиальна).

Время анализа

Кол-во ошибок в эталоне: n

Найдено утечек

Пропущено ошибок из эталона: r

Точность: (n-r)/n

Всего

Верных

Ложных

30 мин

7

2

0

2

7

0 %

Таблица 6. Поиск утечек памяти утилитой PVS-Studio

Вывод

Для поиска утечек памяти этой утилитой можно пользоваться только, если под рукой нет чего-то более заточенного под утечки памяти (Intel Inspector, VLD). Она не способна находить все утечки, но выдает ложные срабатывания. Это не удивительно, поскольку утилита PVS-Studio никогда и не заявлялась как специализированный инструмент поиска утечек.

Сравнение работы инструментов для поиска утечек памяти

Подводя итоги, можно однозначно выделить в качестве лучших 2 инструмента для поиска утечек – Intel Inspector и Visual Leak Detector. На основании проведенного тестирования мы получаем следующую их сравнительную таблицу:  

Intel Inspector

VLD

Вид анализа

Динамический

Динамический

Стабильность работы

Средняя

Высокая

На любом ли примере (сценарии) может отработать

Нет

Нет

Удобство использования

Среднее

Низкое

Замедление debug

9.6 раз

1,7 раз

Замедление release с отладочной информацией

7 раз

Находит ли реальные утечки в debug

Да, все. Избыточность результатов – 18 раз.

Да, все. Избыточность результатов – 26 раз.

Находит ли реальные утечки в release с отладочной информацией

Да, все. Избыточность результатов – 27 раз.

Ложные срабатывания в debug

Да, немного

Нет

Ложные срабатывания в release с отладочной информацией

Да, немного

Можно ли использовать в Continuous Integration

Нет

Да

Таблица7. Сравнение Intel Inspector и VLD.

Место №1 в рейтинге целесообразно отдать VLD, поскольку он не выдает ложных срабатываний, более стабилен в работе и более подходит для использования в сценариях непрерывной интеграции.

When running the code through the clang static analyzer (for instance in Xcode -> Product -> Analyzer), there’s a small number of warnings (mostly uncritical like ‘Values stored to X is never read’), but there are a couple of more critical warning in glslangValidator/StandAlone.cpp where memory is not freed, and a function is potentially called with an unitialized value (very likely a false positive because the code should return, but easy to fix).

Here’s a list of the critical warnings (everything that is not a ‘is never read’ warning):

Potential leak of memory pointed to by ‘config’ in StandAlone.cpp/ProcessConfigFile():

If there’s no config file given, a default ‘config’ is used which is allocated here: https://github.com/KhronosGroup/glslang/blob/master/StandAlone/StandAlone.cpp#L239, the static analyzer thinks that is is never freed.

Apart from that warning, there are several allocations happening in ReadFileData(), which don’t seem to have a corresponding free() call (for instance here: https://github.com/KhronosGroup/glslang/blob/master/StandAlone/StandAlone.cpp#L965.

Also in FreeFileData() it looks like only the ‘node pointers’ are freed, not the array that contains the pointers allocated here: https://github.com/KhronosGroup/glslang/blob/master/StandAlone/StandAlone.cpp#L953

Potential leak of memory pointed to by ‘program’:

The object created here: https://github.com/KhronosGroup/glslang/blob/master/StandAlone/StandAlone.cpp#L619

…is not deleted when this if is taken: https://github.com/KhronosGroup/glslang/blob/master/StandAlone/StandAlone.cpp#L627

Potential leak of memory pointed to by ‘return_data’:

The memory allocated here: https://github.com/KhronosGroup/glslang/blob/master/StandAlone/StandAlone.cpp#L953

Is not freed when this ‘if’ is taken: https://github.com/KhronosGroup/glslang/blob/master/StandAlone/StandAlone.cpp#L956

Function call argument is an unitialized value:

This is very likely a false positive, but easy to fix: clang analyzer says that the ‘FILE* in’ here: https://github.com/KhronosGroup/glslang/blob/master/StandAlone/StandAlone.cpp#L948

…can remain unitialized if the call to fopen_s returns with an error when fgetc() is called here: https://github.com/KhronosGroup/glslang/blob/master/StandAlone/StandAlone.cpp#L960

This is the list of ‘Value stored in X is never read’, some of these may point to actual bugs, not sure:

Value stored to ‘ch’ is never read:

https://github.com/KhronosGroup/glslang/blob/master/glslang/MachineIndependent/preprocessor/PpContext.h#L411

Value stored to ‘fragOutHasLocation’ is never read:

https://github.com/KhronosGroup/glslang/blob/master/glslang/MachineIndependent/linkValidate.cpp#L557

Value stored to ‘token’ is never read:

https://github.com/KhronosGroup/glslang/blob/master/glslang/MachineIndependent/preprocessor/Pp.cpp#L831

Value stored to ‘isVersion’ is never read:

https://github.com/KhronosGroup/glslang/blob/master/glslang/MachineIndependent/preprocessor/Pp.cpp#L867

Value stored to ‘exp’ is never read:

https://github.com/KhronosGroup/glslang/blob/master/glslang/MachineIndependent/preprocessor/PpScanner.cpp#L182

Value stored to ‘word’ is never read:

https://github.com/KhronosGroup/glslang/blob/master/SPIRV/SPVRemapper.cpp#L457
https://github.com/KhronosGroup/glslang/blob/master/SPIRV/SPVRemapper.cpp#L469

Value stored to ‘nextInst’ during its initialization is never read:

https://github.com/KhronosGroup/glslang/blob/master/SPIRV/SPVRemapper.cpp#L469

And that is all :) I can provide a pull request for most of these if you want (the only part that’s a bit more complex is the allocation/free stuff around ReadFileData / FreeFileData).

Если вы давно программируете — наверняка занимались дизассемблированием кода и задавались вопросом «Почему компилятор в одном случае оптимизировал код, а в другом — нет?» Обычно для ответа на вопрос нужно провести небольшое исследование с маленькими кусочками программ. Иногда нужно сравнить результаты работы нескольких компиляторов с разными флагами оптимизации.

Если вы преподаете компьютерные дисциплины — то приводить ассемблерный код очень полезно. Например, вы рассказали студентам о том, как происходит создание переменных «на стеке» или о раскрутке стека при вылете исключения. Стоит показать, что далеко не всегда аргументы передаются через стек, а значит — механизм раскрутки стека должен это учитывать для каждой функции. Сделать это можно с помощью такой программы:

int bar(int a, int b) {
    return a+b;
}

Хотелось бы собрать ее с различными флагами оптимизации и сравнить результаты. Очень удобный инструмент для этого godbolt.

Тут видно, что в этой онлайн-среде вы можете:

  • вводить кусочки кода (не обязательно программу целиком). И получить для них ассемблерное представление;
  • выбрать компилятор (я выбрал gcc10.2) и задать опции компиляции;
  • выбирать одновременно несколько компиляторов — удобно сравнивать результаты;
  • вводить код программы на разных языках. Поддерживается Pascal, Python и море менее популярных языков типа D и Haskell.
  • вводить одновременно несколько фрагментов исходного кода, для каждого из которых добавлять свои компиляторы. Иногда это удобно — например, чтобы показать как оптимизатор кода может учитывать константность или что-то типа noexcept в С++.

Если в коде есть точка входа (в С++ функция main) — то можно посмотреть результат выполнения кода. В этом эта онлайн-среда мало чем отличается от других онлайн-компиляторов, разве что возможностью подключению к проекту популярных библиотек типа boost, Google Benchmark, Google Test, Doctest и так далее.

Кроме того, полученными результатами можно поделиться (есть кнопка Share), при этом вы делитесь не только исходником, но и настроенными компиляторами. Если вы вставляете результаты с godbolt в презентацию, то стоит добавить туда и ссылки — это удобно.

Инструменты статического анализа программ

Не умеющий читать часто считает что «это ненужно», ведь он живет и никакой выгоды от чтения не извлекает. Это касается многих умений и навыков — от работы в Word, Latex, до программирования и иностранных языков. Справедливо это и для навыка использования статических анализаторов кода. Чтобы убедить программиста вешать git-hook, выполняющий статический анализ перед commit — надо показать ему получаемые выгоды. Сделать это надо во время учебного процесса, но несмотря на то, что даже мощный PVS-Studio имеет бесплатную версию для студентов — убедить их начать пользоваться чем-то таким крайне сложно. Немного ускорить процесс можно с помощью онлайн-компиляторов с поддержкой инструментов анализа.

В качестве примера возьмем вот такую программу на С++:

#include <iostream>

class Animal {
    char *name;
public:
    Animal() {
        name = new char[50];
        std::cout << "Animaln";
    }
    /*virtual */~Animal() {
        delete name;
        std::cout << "~Animaln";
    }
    virtual void say() = 0;
};

class Cat : public Animal {
public:
    Cat() {
        std::cout << "Catn";
        color = new char[50];
    }

    ~Cat() {
        std::cout << "~Catn";
        free(color);
    }

    virtual void say() {
        std::cout << "meown";
    }
protected:
   char *color;
};

int main() {
    Animal* p = new Cat();
    p->say();

    //delete p;

    return 0;
}

Какие проблемы с кодом видит студент? В данном небольшом примере несложно заметить, что не выполняется delete p, хотя в проекте из 3-4 тысяч строчек и это будет непросто. Однако, статический анализатор PVS Studio найдет больше проблем:

:26:1: error: V611 The memory was allocated using ‘new’ operator but was released using the ‘free’ function. Consider inspecting operation logics behind the ‘color’ variable. Check lines: 26, 21.:11:1: error: V611 The memory was allocated using ‘new T[]’ operator but was released using the ‘delete’ operator. Consider inspecting this code. It’s probably better to use ‘delete [] name;’. Check lines: 11, 7.

Он обнаружил 2 проблемы:

  1. в 21 строке память выделена через new[], а в 26 — освобождена через free;
  2. в 7 строке память выделяется через new[], а освобождается через delete (надо использовать delete[]).

Выглядит здорово, но используем с этим кодом другой статический анализатор — Clang-Tidy, получим следующее:

:42:5: warning: Potential leak of memory pointed to by ‘p’ [clang-analyzer-cplusplus.NewDeleteLeaks]
return 0;
^:37:17: note: Memory is allocated
Animal* p = new Cat();
^:42:5: note: Potential leak of memory pointed to by ‘p’
return 0;
^
1 warning generated.

Иными словами, одну проблему в коде нашел Clang-Tidy и две других — PVS Studio. Статические анализаторы неплохо дополняют друг друга.

Давайте исправим найденные ошибки и запустим анализ еще раз. Результат работы PVS-Studio:

:40:1: error: V599 The destructor was not declared as a virtual one, although the ‘Animal’ class contains virtual functions.

Clang-Tidy находит эту же проблему:

:40:5: warning: delete called on ‘Animal’ that is abstract but has non-virtual destructor [clang-diagnostic-delete-abstract-non-virtual-dtor]
delete p;
^

В этом коде, на самом деле, происходит утечка памяти, т.к. объект p используется полиморфным образом. Компилятор знает, что это объект типа Animal, а значит delete p приведет к вызову ~Animal(), но во время выполнения будет создан объект класса Cat. Для решения проблемы надо объявить виртуальный деструктор в классе Animal.

Средства статического анализа программ встроены в godbolt, теперь чтобы попробовать их даже не требуется что-либо устанавливать. Такой вариант подойдет только для небольших проектов, но этого достаточно чтобы ощутить достоинства инструментов статического анализа. Сколько времени потребуется среднему студенту чтобы найти все проблемы в приведенном коде (лишь из 40 строк кода)? — Статический анализатор делает это за пару секунд, даже при запуске в онлайне.

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

Если у вас не получается разобраться с интерфейсом godbolt — посмотрите иллюстрированные инструкции.

Обзор инструментов, связанных с обеспечением качества кода можно посмотреть в видео «Гарантии качества для крупных С++ проектов«. Дело в том, что статический анализ — это не все.

Также, может оказаться интересным доклад с SECR 2019 «Современное состояние исследований и разработок в области автоматического анализа программ«. В докладе мало полезного для практики, однако вы узнаете как устроен анализ программ внутри.

Понравилась статья? Поделить с друзьями:
  • Error postcss received undefined instead of css string
  • Error postcss plugin tailwindcss requires postcss 8
  • Error post loading model hlmv
  • Error positive was not declared in this scope
  • Error pos vert variance