Memory Leak Detektor mit C++

C++Beginner
Jetzt üben

Einführung

Memory Leaks sind immer noch eines der schwierigsten Probleme, selbst für erfahrene Programmierer. Selbst wenn sie für eine gewisse Zeit inaktiv sind, kann ein Memory Leak weiterhin auftreten. Neben dem grundlegenden Leak-Phänomen von zugewiesenen Speicherbereichen gibt es viele verschiedene Arten von Memory Leaks, wie z. B. Leaks in Ausnahmefeldern usw. Dieses Projekt führt Sie durch die Implementierung eines Memory Leak Detektors.

Dinge, die Sie lernen können

  • Überladen des Operators new
  • Vordefinierte Makros __FILE__ und __LINE__
  • Statische Variablen in der Kopfdatei
  • Smart Pointer std::shared_ptr

Code-Test

Memory Leaks hängen normalerweise mit versehentlich zugewiesenen Speicherbereichen zusammen, die nicht freigegeben werden. In modernen Betriebssystemen wird solch ein nicht freigegebener Speicher von dem Betriebssystem nach Beendigung einer Anwendung, die den regulären Speicher verwendet hat, wieder freigegeben. Ein Memory Leak, das durch eine vorübergehende Anwendung verursacht wird, hat daher keine schwerwiegenden Folgen.

Wenn wir jedoch eine Anwendung wie einen Server schreiben, wird diese ständig im Betrieb sein. Wenn es logische Fehler gibt, die zu einem Memory Leak führen, besteht die Möglichkeit, dass das Speicherleck sich ständig erhöht. Schließlich wird die Leistung des Systems reduziert und es kann sogar zu einem Betriebsausfall kommen.

Ein laufendes Programm, das ständig Speicher zuweist, ist ziemlich einfach:

int main() {
    // weist ständig Speicher zu, gibt ihn nie frei
    while(1) int *a = new int;
    return 0;
}

Um Memory Leaks in einem laufenden Programm zu erkennen, legen wir in der Anwendung üblicherweise Prüfpunkte fest, um zu analysieren, ob sich der Speicher gegenüber anderen Prüfpunkten geändert hat. Im Wesentlichen ist dies ähnlich wie eine Prüfung auf kurzfristiges Memory Leaking in einem Programm. Daher wird der Memory Leak Detektor in diesem Projekt für kurzfristige Memory Leaks implementiert.

Wir hoffen, dass der folgende Testcode einen Memory Leak erkennen kann. In diesem Code geben wir den zugewiesenen Speicher nicht frei und erzeugen so einen Memory Leak aufgrund eines Ausnahmefeldes:

//
//  main.cpp
//  LeakDetector
//
#include <iostream>

// Hier wird die Memory Leak Prüfung implementiert
// #include "LeakDetector.hpp"

// testet das Verhalten eines Ausnahmefeldes, das zu einem Memory Leak führt
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: Vergessen, Zeiger b freizugeben
    int *a = new int;
    int *b = new int[12];

    delete a;

    // Memory Leak: Der Konstruktorparameter 0 verursacht ein Ausnahmefeld, das zu einem Memory Leak führt
    try {
        Err* e = new Err(0);
        delete e;
    } catch (int &ex) {
        std::cout << "Exception: " << ex << std::endl;
    };

    return 0;
}

Wenn wir den Codeausschnitt #include "LeakDetector.hpp" auskommentieren, kann dieser Code wie folgt normal ausgeführt werden. Natürlich wissen wir jedoch, dass er tatsächlich zu einem Memory Leak führt:

Das Ergebnis:

image desc

Den Memory Leak Detektor entwerfen

Um die Erkennung von Memory Leaks zu erreichen, betrachten wir die folgenden Aspekte:

  1. Memory Leaks werden erzeugt, wenn nach einem new-Vorgang kein delete ausgeführt wird.
  2. Der Destruktor des zuerst erstellten Objekts wird immer am Ende ausgeführt.

Anschließend können wir die folgenden Aktionen entsprechend den beiden oben genannten Punkten ausführen:

  1. Überladen des new-Operators.
  2. Erstellen eines statischen Objekts und Aufrufen des Destruktors am Ende des ursprünglichen Programms.

Der Vorteil dieser beiden Schritte besteht darin, dass die Memory Leak-Erkennung ohne Änderung des ursprünglichen Codes durchgeführt werden kann. Daher können wir LeakDetector.hpp schreiben:

//
//  /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);
// Dieser Makro wird in LeakDetector.cpp beschrieben
#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

Warum wird ein callCount entworfen? Der callCount gewährleistet, dass unser LeakDetector nur einmal aufgerufen werden kann. Betrachten Sie den folgenden vereinfachten Codeausschnitt:

// 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;

Die endgültige Ausgabe ist 1, 2,. Dies liegt daran, dass die statischen Variablen in der Kopfdatei mehrfach definiert werden und jede Mal, wenn sie in einer .cpp-Datei enthalten sind, erneut definiert werden. (Im Falle, dass sie in main.cpp und test.cpp enthalten sind.) Im Grunde genommen sind es jedoch dieselben Objekte.

Nun bleibt die verbleibende Frage: Wie implementiert man einen Memory Leak Detektor?

Ein Memory Leak Detektor zu implementieren

Lassen Sie uns unseren Memory Leak Detektor implementieren.

Da wir bereits den Operator new überladen haben, fällt es leicht, die Speicherzuweisung manuell zu verwalten und den Speicher natürlich freizugeben. Wenn unser delete nicht alle zugewiesenen Speicherbereiche freigibt, wird ein Memory Leak auftreten. Jetzt ist die Frage: Welche Struktur sollten wir verwenden, um den Speicher manuell zu verwalten?

Eine doppelt verkettete Liste ist eine gute Option. Für den tatsächlichen Code wissen wir nicht, wann wir Speicher zuweisen müssen. Daher ist eine einseitige lineare Liste nicht ausreichend, aber eine dynamische Struktur (z. B. eine Liste) ist viel bequemer. Außerdem ist eine einseitige lineare Liste auch nicht bequem, wenn es darum geht, das Objekt im Memory Leak Detektor zu löschen.

Somit ist die doppelt verkettete Liste momentan unsere beste Option:

#include <iostream>
#include <cstring>

// Definiert die __NEW_OVERLOAD_IMPLEMENTATION__ Makros,
// verhindert das Überschreiben des Operators `new`
#define __NEW_OVERLOAD_IMPLEMENTATION__
#include "LeakDetector.hpp"

typedef struct _MemoryList {
    struct  _MemoryList *next, *prev;
    size_t  size;       // Größe des zugewiesenen Speichers
    bool    isArray;    // Ob es sich um ein zugewiesenes Array handelt oder nicht
    char    *file;      // Datei, in der der Leak auftritt
    unsigned int line;  // Zeile, in der der Leak auftritt
} _MemoryList;
static unsigned long _memory_allocated = 0;     // Speichert die Größe des nicht freigegebenen Speichers
static _MemoryList _root = {
    &_root, &_root,     // Zeiger auf das erste Element zeigen alle auf sich selbst
    0, false,           // 0 zugewiesene Speichergröße, kein Array
    NULL, 0             // Keine Datei, Zeile 0
};

unsigned int _leak_detector::callCount = 0;

Lassen Sie uns jetzt den zugewiesenen Speicher manuell verwalten:

// Speicherzuweisung beginnt am Anfang der _MemoryList
void* AllocateMemory(size_t _size, bool _array, char *_file, unsigned _line) {
    // Berechne die neue Größe
    size_t newSize = sizeof(_MemoryList) + _size;

    // Wir können nur malloc verwenden, um die Zuweisung durchzuführen, da new bereits überschrieben wurde.
    _MemoryList *newElem = (_MemoryList*)malloc(newSize);

    newElem->next = _root.next;
    newElem->prev = &_root;
    newElem->size = _size;
    newElem->isArray = _array;
    newElem->file = NULL;

    // Speichere die Dateiinformation, wenn vorhanden
    if (_file) {
        newElem->file = (char *)malloc(strlen(_file)+1);
        strcpy(newElem->file, _file);
    }
    // Speichere die Zeilennummer
    newElem->line = _line;

    // Aktualisiere die Liste
    _root.next->prev = newElem;
    _root.next = newElem;

    // Speichere die Größe des nicht freigegebenen Speichers
    _memory_allocated += _size;

    // Gebe den zugewiesenen Speicher zurück
    return (char*)newElem + sizeof(_MemoryList);
}

und auch den Operator delete:

void  DeleteMemory(void* _ptr, bool _array) {
    // Gehe zurück zum Anfang der MemoryList
    _MemoryList *currentElem = (_MemoryList *)((char *)_ptr - sizeof(_MemoryList));

    if (currentElem->isArray!= _array) return;

    // Aktualisiere die Liste
    currentElem->prev->next = currentElem->next;
    currentElem->next->prev = currentElem->prev;
    _memory_allocated -= currentElem->size;

    // Gebe den zugewiesenen Speicher für die Dateiinformation frei
    if (currentElem->file) free(currentElem->file);
    free(currentElem);
}

Beachten Sie, dass wir zwei verschiedene Wege haben, new und delete zu verwenden:

void* operator new(size_t _size) {
    return AllocateMemory(_size, false, NULL, 0);
}
void* operator new[](size_t _size) {
    return AllocateMemory(_size, true, NULL, 0);
}
void* operator new(size_t _size, char *_file, unsigned int _line) {
    return AllocateMemory(_size, false, _file, _line);
}
void* operator new[](size_t _size, char *_file, unsigned int _line) {
    return AllocateMemory(_size, true, _file, _line);
}
// Überschreibe den Operator delete
void operator delete(void *_ptr) noexcept {
    DeleteMemory(_ptr, false);
}
void operator delete[](void *_ptr) noexcept {
    DeleteMemory(_ptr, true);
}

Schließlich müssen wir _leak_detector::LeakDetector implementieren und es wird im Destruktor von static _leak_detector _exit_counter aufgerufen. Dann werden alle zugewiesenen Objekte freigegeben. Ein Memory Leak tritt auf, wenn static _MemoryList _root nicht leer ist, und wir müssen nur durch _root iterieren, um zu finden, wo der Speicherleak ist:

unsigned int _leak_detector::LeakDetector(void) noexcept {
    unsigned int count = 0;
    // Iteriere die gesamte Liste, wenn es einen Memory Leak gibt,
    // dann zeigt _LeakRoot.next immer nicht auf sich selbst
    _MemoryList *ptr = _root.next;
    while (ptr && ptr!= &_root)
    {
        // Gib alle Leak-Informationen aus
        if(ptr->isArray)
            std::cout << "Leaking[] ";
        else
            std::cout << "Leaking   ";
        std::cout << ptr << ", Größe von " << ptr->size;
        if (ptr->file)
            std::cout << " (lokalisiert in " << ptr->file << ", Zeile " << ptr->line << ")";
        else
            std::cout << " (keine Dateiinformation)";
        std::cout << std::endl;

        ++count;
        ptr = ptr->next;
    }

    if (count)
        std::cout << "Es gibt " << count << " Memory Leak-Verhaltensweisen, insgesamt " << _memory_allocated << " Byte." << std::endl;
    return count;
}

Lassen Sie uns alles kompilieren:

g++ main.cpp LeakDetector.cpp -std=c++11 -Wno-write-strings

In main.cpp kommentieren Sie die Zeile include "LeakDetector.hpp" aus. Hier ist die erwartete Ausgabe:

image desc

Zusammenfassung

In diesem Lab überschreiben wir den Operator new in C++ und implementieren einen Memory Leak Detektor. Tatsächlich kann der Code auch für das Leaking von zirkulären Referenzen von Smart Pointern verwendet werden. Wir müssen nur main.cpp wie folgt ändern:

#include <memory> // Verwende Smart Pointer

// Keine Änderungen am Anfang wie zuvor in main.cpp

// Füge zwei Testklassen hinzu
class A;
class B;
class A {
public:
    std::shared_ptr<B> p;
};
class B {
public:
    std::shared_ptr<A> p;
};

int main() {

    // Änderungen hier wie zuvor in main.cpp

    // Zusätzliche Fälle, Verwende Smart Pointer
    auto smartA = std::make_shared<A>();
    auto smartB = std::make_shared<B>();
    smartA->p = smartB;
    smartB->p = smartA;

    return 0;

}

Unsere endgültigen Ausgaben sind:

image desc

Für einige von Ihnen kann das Ergebnis sein: image desc

Ihr Ergebnis kann unterschiedlich sein. Beide der obigen sind richtig, da sich Ihre Umgebung in Bezug auf Maschine und Compiler unterscheiden kann.

Zusammenfassung

  1. Memory Leak - Wikipedia