Потеря памяти всегда была одной из самых сложных проблем, даже для опытных программистов. Потеря памяти продолжается, когда они отвлекаются в течение некоторого времени. Кроме базового явления утечки выделенной памяти, существуют многие разные виды утечки памяти, такие как утечка в ветке исключения и т.д. В этом проекте показано, как реализовать детектор утечек памяти.
Что нужно изучить
Переопределить оператор new
Предопределенные макросы __FILE__ и __LINE__
Статические переменные в заголовочном файле
Умный указатель std::shared_ptr
Skills Graph
%%%%{init: {'theme':'neutral'}}%%%%
flowchart RL
cpp(("C++")) -.-> cpp/BasicsGroup(["Basics"])
cpp(("C++")) -.-> cpp/OOPGroup(["OOP"])
cpp(("C++")) -.-> cpp/AdvancedConceptsGroup(["Advanced Concepts"])
cpp/BasicsGroup -.-> cpp/operators("Operators")
cpp/OOPGroup -.-> cpp/classes_objects("Classes/Objects")
cpp/OOPGroup -.-> cpp/class_methods("Class Methods")
cpp/OOPGroup -.-> cpp/constructors("Constructors")
cpp/AdvancedConceptsGroup -.-> cpp/pointers("Pointers")
cpp/AdvancedConceptsGroup -.-> cpp/exceptions("Exceptions")
subgraph Lab Skills
cpp/operators -.-> lab-178620{{"Детектор утечек памяти на C++"}}
cpp/classes_objects -.-> lab-178620{{"Детектор утечек памяти на C++"}}
cpp/class_methods -.-> lab-178620{{"Детектор утечек памяти на C++"}}
cpp/constructors -.-> lab-178620{{"Детектор утечек памяти на C++"}}
cpp/pointers -.-> lab-178620{{"Детектор утечек памяти на C++"}}
cpp/exceptions -.-> lab-178620{{"Детектор утечек памяти на C++"}}
end
Тестирование кода
Потери памяти обычно связаны с неосознанно выделенной памятью, которая остается не освобожденной. В современных операционных системах такая неосвобождаемая память все равно будет освобождена операционной системой после завершения работы одной из приложений, которые использовали обычную память. Поэтому утечка памяти, вызванная временным приложением, не вызовет серьезных последствий.
Однако, если мы пишем приложение, такое как сервер, оно будет постоянно работать. Если в логике приложения есть что-то, что приводит к утечке памяти, то есть большая вероятность, что утечка памяти будет постоянно увеличиваться. В конечном итоге производительность системы будет снижаться, и это может привести даже к сбою в работе.
Программа, которая постоянно выделяет память, довольно проста:
int main() {
// allocates memory forever, never release
while(1) int *a = new int;
return 0;
}
Для обнаружения утечек памяти в работающей программе мы обычно ставим контрольные точки в приложении, чтобы проанализировать, изменилась ли память по сравнению с другими контрольными точками.
По сути, это похоже на проверку на утечку памяти в программе на короткий срок. Поэтому детектор утечек памяти в этом проекте будет реализован для обнаружения утечек памяти на короткий срок.
Мы надеемся, что следующий тестовый код сможет обнаружить утечку памяти. В этом коде мы не освобождаем выделенную память и создаем утечку памяти, вызванную веткой исключения:
//
// main.cpp
// LeakDetector
//
#include <iostream>
// Implements memory leak inspection here
// #include "LeakDetector.hpp"
// testing exception branch leaking behavior
class Err {
public:
Err(int n) {
if(n == 0) throw 1000;
data = new int[n];
}
~Err() {
delete[] data;
}
private:
int *data;
};
int main() {
// Memory Leak: forget release pointer b
int *a = new int;
int *b = new int[12];
delete a;
// Memory Leak: 0 as constructor parameter causes exception leaking
try {
Err* e = new Err(0);
delete e;
} catch (int &ex) {
std::cout << "Exception: " << ex << std::endl;
};
return 0;
}
Если мы закомментируем фрагмент кода #include "LeakDetector.hpp", то этот код может быть запущен нормально, как показано ниже. Однако, конечно, мы знаем, что на самом деле это приводит к утечке памяти:
Результат:
Конструирование детектора утечек памяти
Для реализации обнаружения утечек памяти рассмотрим следующие аспекты:
Утечки памяти возникают, если после операции new не выполняется delete.
Деструктор первого созданного объекта всегда выполняется в конце.
Тогда можно предпринять следующие действия, соответствующие двум вышеперечисленным пунктам:
Перегрузить оператор new.
Создать статический объект и вызвать деструктор в конце исходной программы.
Преимуществом таких двух шагов является то, что обнаружение утечек памяти можно выполнить без изменения исходного кода. Таким образом, можно написать LeakDetector.hpp:
//
// /home/labex/Code/LeakDetector.hpp
//
#ifndef __LEAK_DETECTOR__
#define __LEAK_DETECTOR__
void* operator new(size_t _size, char *_file, unsigned int _line);
void* operator new[](size_t _size, char *_file, unsigned int _line);
// Этот макрос будет описан в LeakDetector.cpp
#ifndef __NEW_OVERLOAD_IMPLEMENTATION__
#define new new(__FILE__, __LINE__)
#endif
class _leak_detector
{
public:
static unsigned int callCount;
_leak_detector() noexcept {
++callCount;
}
~_leak_detector() noexcept {
if (--callCount == 0)
LeakDetector();
}
private:
static unsigned int LeakDetector() noexcept;
};
static _leak_detector _exit_counter;
#endif
Почему нужно конструировать callCount? callCount гарантирует, что наш LeakDetector может быть вызван только один раз. Рассмотрим следующий упрощенный фрагмент кода:
// main.cpp
#include <iostream>
#include "test.h"
int main () {
return 0;
}
// test.hpp
#include <iostream>
class Test {
public:
static unsigned int count;
Test () {
++count;
std::cout << count << ",";
}
};
static Test test;
// test.cpp
#include "test.hpp"
unsigned int Test::count = 0;
Финальный вывод будет 1, 2,.
Это происходит потому, что статические переменные в заголовочном файле определяются несколько раз, и они будут определены каждый раз, когда они включаются в .cpp файл. (В данном случае, они будут включены в main.cpp и test.cpp.) Тем не менее, по сути, они представляют один и тот же объект.
Теперь остается вопрос: Как реализовать детектор утечек памяти?
Реализация детектора утечек памяти
Реализуем наш детектор утечек памяти.
Поскольку мы уже перегрузили оператор new, легко подумать о ручном управлении выделением памяти и естественном освобождении памяти. Если наш delete не освобождает всю выделенную память, то произойдет утечка памяти. Теперь вопрос: какой структурой мы будем управлять памятью вручную?
Двусвязный список - хороший выбор. В реальном коде мы не знаем, когда нам нужно выделить память, поэтому односвязный линейный список недостаточен, а динамическая структура (например, список) будет гораздо более удобной.
此外, односвязный линейный список также не удобен при удалении объекта в детекторе утечек памяти.
Таким образом, двусвязный список - наилучший выбор в данный момент:
#include <iostream>
#include <cstring>
// Определение макроса __NEW_OVERLOAD_IMPLEMENTATION__,
// предотвращающего перезапись оператора `new`
#define __NEW_OVERLOAD_IMPLEMENTATION__
#include "LeakDetector.hpp"
typedef struct _MemoryList {
struct _MemoryList *next, *prev;
size_t size; // размер выделенной памяти
bool isArray; // выделен массив или нет
char *file; // файл, где произошла утечка
unsigned int line; // строка, где произошла утечка
} _MemoryList;
static unsigned long _memory_allocated = 0; // сохранение размера неосвобожденной памяти
static _MemoryList _root = {
&_root, &_root, // указатели первого элемента указывают на себя
0, false, // 0 размер выделенной памяти, не массив
NULL, 0 // нет файла, строка 0
};
unsigned int _leak_detector::callCount = 0;
Теперь давайте вручную управляем выделенную память:
// выделение памяти начинается с головы _MemoryList
void* AllocateMemory(size_t _size, bool _array, char *_file, unsigned _line) {
// вычисление нового размера
size_t newSize = sizeof(_MemoryList) + _size;
// мы можем использовать только malloc для выделения, так как new уже перезаписан.
_MemoryList *newElem = (_MemoryList*)malloc(newSize);
newElem->next = _root.next;
newElem->prev = &_root;
newElem->size = _size;
newElem->isArray = _array;
newElem->file = NULL;
// сохраняем информацию о файле, если есть
if (_file) {
newElem->file = (char *)malloc(strlen(_file)+1);
strcpy(newElem->file, _file);
}
// сохраняем номер строки
newElem->line = _line;
// обновляем список
_root.next->prev = newElem;
_root.next = newElem;
// сохраняем в размер неосвобожденной памяти
_memory_allocated += _size;
// возвращаем выделенную память
return (char*)newElem + sizeof(_MemoryList);
}
и оператор delete также:
void DeleteMemory(void* _ptr, bool _array) {
// возвращаемся к началу MemoryList
_MemoryList *currentElem = (_MemoryList *)((char *)_ptr - sizeof(_MemoryList));
if (currentElem->isArray!= _array) return;
// обновляем список
currentElem->prev->next = currentElem->next;
currentElem->next->prev = currentElem->prev;
_memory_allocated -= currentElem->size;
// освобождаем выделенную память для информации о файле
if (currentElem->file) free(currentElem->file);
free(currentElem);
}
Рассмотрим, что у нас есть два разных способа использовать new и delete:
Наконец, нам нужно реализовать _leak_detector::LeakDetector, и оно будет вызываться в деструкторе static _leak_detector _exit_counter. Затем все выделенные объекты будут освобождены. Утечка памяти возникает в том случае, если static _MemoryList _root не пуст, и нам нужно только перебрать _root, чтобы найти, где произошла утечка памяти:
unsigned int _leak_detector::LeakDetector(void) noexcept {
unsigned int count = 0;
// перебираем весь список, если есть утечка памяти,
// то _LeakRoot.next всегда не указывает на себя
_MemoryList *ptr = _root.next;
while (ptr && ptr!= &_root)
{
// выводим всю информацию о утечке
if(ptr->isArray)
std::cout << "Leaking[] ";
else
std::cout << "Leaking ";
std::cout << ptr << ", размер " << ptr->size;
if (ptr->file)
std::cout << " (находится в " << ptr->file << ", строка " << ptr->line << ")";
else
std::cout << " (нет информации о файле)";
std::cout << std::endl;
++count;
ptr = ptr->next;
}
if (count)
std::cout << "Есть " << count << " случаев утечки памяти, " << _memory_allocated << " байт в сумме." << std::endl;
return count;
}
В main.cpp раскомментируем include "LeakDetector.hpp". Вот ожидаемый вывод:
Резюме
В этом практическом занятии мы перезаписываем оператор new в C++ и реализуем детектор утечек памяти.
Фактически, этот код также можно использовать для обнаружения утечек при круговой ссылке умных указателей. Мы只需 изменить main.cpp следующим образом:
#include <memory> // use smart pointer
// no changes at the begining as previous in main.cpp
// add two testing class
class A;
class B;
class A {
public:
std::shared_ptr<B> p;
};
class B {
public:
std::shared_ptr<A> p;
};
int main() {
// changes here as previous in main.cpp
// additional cases, use smart pointer
auto smartA = std::make_shared<A>();
auto smartB = std::make_shared<B>();
smartA->p = smartB;
smartB->p = smartA;
return 0;
}
Наши итоговые выводы выглядят так:
Для некоторых из вас результат может быть таким:
Ваш результат может отличаться. Каждый из двух приведенных выше вариантов правильный, потому что у вас может быть разница в настройках машины и компиляторе.