El escape de memoria siempre ha sido uno de los problemas más difíciles, incluso para los programadores senior. El escape de memoria sigue existiendo cuando se les olvida por un tiempo. Además del fenómeno básico de escape de memoria asignada, hay muchos tipos diferentes de escape de memoria, como el escape en ramas de excepciones, etc. Este proyecto te guía para implementar un detector de escape de memoria.
Cosas que se pueden aprender
Sobrescribir el operador new
Macros predefinidas __FILE__ y __LINE__
Variables estáticas en el archivo de encabezado
Smart pointer 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{{"Detector de escapes de memoria con C++"}}
cpp/classes_objects -.-> lab-178620{{"Detector de escapes de memoria con C++"}}
cpp/class_methods -.-> lab-178620{{"Detector de escapes de memoria con C++"}}
cpp/constructors -.-> lab-178620{{"Detector de escapes de memoria con C++"}}
cpp/pointers -.-> lab-178620{{"Detector de escapes de memoria con C++"}}
cpp/exceptions -.-> lab-178620{{"Detector de escapes de memoria con C++"}}
end
Prueba de código
Los escapes de memoria suelen estar relacionados con la memoria asignada inadvertidamente que permanece sin liberarse. En los sistemas operativos modernos, esta memoria no liberada todavía será recuperada por el sistema operativo después de que la memoria regular haya sido utilizada por una aplicación que se ha terminado, por lo que un escape de memoria causado por una aplicación transitoria no causará consecuencias graves.
Sin embargo, si estamos escribiendo una aplicación como un servidor, siempre estará en funcionamiento. Si hay alguna lógica que conduce a un escape de memoria, es muy probable que siga aumentando el escape de memoria. Finalmente, el rendimiento del sistema se reducirá e incluso puede causar un fallo en la operación.
Un programa en ejecución que sigue asignando memoria continuamente es bastante simple:
int main() {
// asigna memoria para siempre, nunca la libera
while(1) int *a = new int;
return 0;
}
Para detectar escapes de memoria en un programa en ejecución, generalmente establecemos puntos de control en la aplicación para analizar si la memoria cambia con respecto a otros puntos de control.
En esencia, es similar a una inspección de escape de memoria de programa a corto plazo. Entonces, el detector de escapes de memoria de este proyecto se implementará para un escape de memoria a corto plazo.
Esperamos que el siguiente código de prueba pueda detectar un escape de memoria. En este código, no liberamos la memoria asignada y creamos un escape de memoria causado por una rama de excepción:
//
// main.cpp
// LeakDetector
//
#include <iostream>
// Implementa la inspección de escape de memoria aquí
// #include "LeakDetector.hpp"
// Prueba el comportamiento de escape de excepción
class Err {
public:
Err(int n) {
if(n == 0) throw 1000;
data = new int[n];
}
~Err() {
delete[] data;
}
private:
int *data;
};
int main() {
// Escape de memoria: olvida liberar el puntero b
int *a = new int;
int *b = new int[12];
delete a;
// Escape de memoria: 0 como parámetro del constructor causa un escape de excepción
try {
Err* e = new Err(0);
delete e;
} catch (int &ex) {
std::cout << "Excepción: " << ex << std::endl;
};
return 0;
}
Si comentamos el fragmento de código #include "LeakDetector.hpp", entonces este código se puede ejecutar normalmente como se muestra a continuación. Sin embargo, por supuesto, sabemos que en realidad conduce a escapes de memoria:
El resultado:
Diseño del detector de escapes de memoria
Para lograr la detección de escapes de memoria, consideremos los siguientes puntos:
Los escapes de memoria se generan si no se ha realizado delete después de la operación new.
El destructor del primer objeto creado siempre se ejecuta al final.
Luego, podemos realizar las siguientes acciones en correspondencia con los dos puntos anteriores:
Sobrecargar el operador new.
Crear un objeto estático y llamar al destructor al final del programa original.
La ventaja de estos dos pasos es que se puede detectar el escape de memoria sin modificar el código original. Así, podemos escribir 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);
// Esta macro se describirá en 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
¿Por qué diseñar un callCount? El callCount asegura que nuestro LeakDetector solo se pueda invocar una vez. Considere el siguiente fragmento de código simplificado:
// 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;
La salida final es 1, 2,.
Esto es porque las variables estáticas en el archivo de encabezado se definen múltiples veces, y se definirán cada vez que se incluyan en un archivo .cpp. (En caso de que se incluyan en main.cpp y test.cpp.) Sin embargo, esencialmente son el mismo objeto.
Ahora, la pregunta restante es: ¿Cómo implementar un detector de escapes de memoria?
Implementar un detector de escapes de memoria
Implementemos nuestro detector de escapes de memoria.
Dado que ya hemos sobrecargado el operador new, es fácil pensar en manejar manualmente la asignación de memoria y liberar naturalmente la memoria. Si nuestra delete no libera toda la memoria asignada, entonces se producirá un escape de memoria. Ahora, la pregunta es: ¿Qué estructura deberíamos usar para manejar manualmente la memoria?
La lista enlazada bidireccional es una buena opción. Para el código real, no sabemos cuándo necesitamos asignar memoria, por lo que una lista lineal unidireccional no es lo suficientemente buena, pero una estructura dinámica (por ejemplo, una lista enlazada) será mucho más conveniente.
Además, la lista lineal unidireccional también no es conveniente cuando se elimina el objeto en el detector de escapes de memoria.
Por lo tanto, la lista enlazada bidireccional es nuestra mejor opción en este momento:
#include <iostream>
#include <cstring>
// Definiendo las macros __NEW_OVERLOAD_IMPLEMENTATION__,
// evita la sobreescritura del operador `new`
#define __NEW_OVERLOAD_IMPLEMENTATION__
#include "LeakDetector.hpp"
typedef struct _MemoryList {
struct _MemoryList *next, *prev;
size_t size; // tamaño de la memoria asignada
bool isArray; // si es un array asignado o no
char *file; // ubicando el archivo con escape
unsigned int line; // ubicando la línea con escape
} _MemoryList;
static unsigned long _memory_allocated = 0; // guardando el tamaño de la memoria no liberada
static _MemoryList _root = {
&_root, &_root, // apuntadores del primer elemento apuntando todos a sí mismo
0, false, // 0 tamaño de memoria asignada, no es un array
NULL, 0 // sin archivo, línea 0
};
unsigned int _leak_detector::callCount = 0;
Ahora, manejemos manualmente la memoria asignada:
// la asignación de memoria comienza desde el principio de _MemoryList
void* AllocateMemory(size_t _size, bool _array, char *_file, unsigned _line) {
// calcular el nuevo tamaño
size_t newSize = sizeof(_MemoryList) + _size;
// solo podemos usar malloc para la asignación ya que new ya ha sido sobreescrito.
_MemoryList *newElem = (_MemoryList*)malloc(newSize);
newElem->next = _root.next;
newElem->prev = &_root;
newElem->size = _size;
newElem->isArray = _array;
newElem->file = NULL;
// guardar la información del archivo si existe
if (_file) {
newElem->file = (char *)malloc(strlen(_file)+1);
strcpy(newElem->file, _file);
}
// guardar el número de línea
newElem->line = _line;
// actualizar la lista
_root.next->prev = newElem;
_root.next = newElem;
// guardar en el tamaño de la memoria no liberada
_memory_allocated += _size;
// devolver la memoria asignada
return (char*)newElem + sizeof(_MemoryList);
}
y el operador delete también:
void DeleteMemory(void* _ptr, bool _array) {
// volver al principio de MemoryList
_MemoryList *currentElem = (_MemoryList *)((char *)_ptr - sizeof(_MemoryList));
if (currentElem->isArray!= _array) return;
// actualizar la lista
currentElem->prev->next = currentElem->next;
currentElem->next->prev = currentElem->prev;
_memory_allocated -= currentElem->size;
// liberar la memoria asignada para la información del archivo
if (currentElem->file) free(currentElem->file);
free(currentElem);
}
Teniendo en cuenta que tenemos dos maneras diferentes de usar new y delete:
Finalmente, necesitamos implementar _leak_detector::LeakDetector y se llamará en el destructor de static _leak_detector _exit_counter. Entonces todos los objetos asignados se liberarán. El escape de memoria aparece en el caso de que static _MemoryList _root no esté vacío, y solo necesitamos iterar _root para encontrar dónde está el escape de memoria:
unsigned int _leak_detector::LeakDetector(void) noexcept {
unsigned int count = 0;
// iterar toda la lista, si hay un escape de memoria,
// entonces _LeakRoot.next siempre no apuntará a sí mismo
_MemoryList *ptr = _root.next;
while (ptr && ptr!= &_root)
{
// mostrar toda la información de escape
if(ptr->isArray)
std::cout << "Escape[] ";
else
std::cout << "Escape ";
std::cout << ptr << ", tamaño de " << ptr->size;
if (ptr->file)
std::cout << " (ubicado en " << ptr->file << ", línea " << ptr->line << ")";
else
std::cout << " (sin información de archivo)";
std::cout << std::endl;
++count;
ptr = ptr->next;
}
if (count)
std::cout << "Existen " << count << " comportamientos de escape de memoria, "<< _memory_allocated << " bytes en total." << std::endl;
return count;
}
En main.cpp, descomente la línea include "LeakDetector.hpp". Aquí está la salida esperada:
Resumen
En este laboratorio, sobreescribimos el operador new en C++ y implementamos un detector de escapes de memoria.
De hecho, el código también se puede utilizar para detectar escapes en referencias circulares de smart pointers. Solo necesitamos cambiar main.cpp de la siguiente manera:
#include <memory> // usar smart pointer
// no hay cambios al principio como en el main.cpp anterior
// agregar dos clases de prueba
class A;
class B;
class A {
public:
std::shared_ptr<B> p;
};
class B {
public:
std::shared_ptr<A> p;
};
int main() {
// cambios aquí como en el main.cpp anterior
// casos adicionales, usar smart pointer
auto smartA = std::make_shared<A>();
auto smartB = std::make_shared<B>();
smartA->p = smartB;
smartB->p = smartA;
return 0;
}
Nuestras salidas finales son:
Para algunos de ustedes, quizás el resultado sea:
Su resultado puede ser diferente. Cada uno de los dos anteriores es correcto, porque su entorno puede variar en cuanto a la máquina y el compilador.