Détecteur de fuites mémoire avec C++

C++Beginner
Pratiquer maintenant

Introduction

Fuite mémoire a toujours été l'un des problèmes les plus difficiles, même pour les programmeurs expérimentés. La fuite mémoire continue même lorsqu'ils sont distraits pendant un certain temps. En plus du phénomène de base de fuite de mémoire allouée, il existe de nombreux types différents de fuites mémoire, telles que la fuite dans les branches d'exception, etc. Ce projet vous guide pour implémenter un détecteur de fuites mémoire.

Choses à apprendre

  • Pour écraser l'opérateur new
  • Macros prédéfinies __FILE__ et __LINE__
  • Variables statiques dans le fichier d'en-tête
  • Smart pointer std::shared_ptr

Test du code

Les fuites mémoire sont généralement liées à la mémoire allouée involontairement qui reste non libérée. Dans les systèmes d'exploitation modernes, cette mémoire non libérée sera toujours récupérée par le système d'exploitation après que la mémoire normale utilisée par une application qui est terminée, donc une fuite mémoire causée par une application transitoire ne causera pas de conséquences graves.

Cependant, si nous écrivons une application telle qu'un serveur, elle sera toujours en cours d'exécution. S'il existe une logique entraînant une fuite mémoire, il est très probable qu'elle continuera d'augmenter la fuite de mémoire. En fin de compte, les performances du système seront réduites et il peut même entraîner une panne de fonctionnement.

Un programme en cours d'exécution qui continue à allouer de la mémoire est assez simple :

int main() {
    // alloue de la mémoire à l'infini, ne la libère jamais
    while(1) int *a = new int;
    return 0;
}

Pour détecter les fuites mémoire dans un programme en cours d'exécution, nous mettons généralement des points de contrôle dans l'application pour analyser si la mémoire devient différente par rapport à d'autres points de contrôle. Essentiellement, il est similaire à une inspection de fuite mémoire de programme à court terme. Ainsi, le détecteur de fuites mémoire de ce projet sera implémenté pour une fuite mémoire à court terme.

Nous espérons que le code de test suivant peut détecter une fuite mémoire. Dans ce code, nous ne libérons pas la mémoire allouée et créons une fuite mémoire causée par une branche d'exception :

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

// Implémente l'inspection de fuite mémoire ici
// #include "LeakDetector.hpp"

// test du comportement de fuite d'exception
class Err {
public:
    Err(int n) {
        if(n == 0) throw 1000;
        data = new int[n];
    }
    ~Err() {
        delete[] data;
    }
private:
    int *data;
};

int main() {

    // Fuite mémoire : oubli de libérer le pointeur b
    int *a = new int;
    int *b = new int[12];

    delete a;

    // Fuite mémoire : 0 en tant que paramètre du constructeur cause une fuite d'exception
    try {
        Err* e = new Err(0);
        delete e;
    } catch (int &ex) {
        std::cout << "Exception: " << ex << std::endl;
    };

    return 0;
}

Si nous commentons le bout de code #include "LeakDetector.hpp", alors ce code peut être exécuté normalement comme indiqué ci-dessous. Cependant, bien sûr, nous savons qu'il conduit en fait à des fuites mémoire :

Le résultat :

image desc

Concevoir un détecteur de fuites mémoire

Pour réaliser la détection de fuites mémoire, considérons les points suivants :

  1. Les fuites mémoire sont générées si delete n'a pas été effectué après l'opération new.
  2. Le destructeur de l'objet créé en premier est toujours exécuté en fin de programme.

Ensuite, nous pouvons effectuer les actions suivantes correspondant aux deux points ci-dessus :

  1. Surcharger l'opérateur new.
  2. Créer un objet statique et appeler le destructeur à la fin du programme original.

L'avantage de ces deux étapes est que la détection de fuites mémoire peut être effectuée sans modifier le code original. Ainsi, nous pouvons écrire 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);
// Cette macro sera décrite dans 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

Pourquoi concevoir un callCount? Le callCount assure que notre LeakDetector ne peut être invoqué qu'une seule fois. Considérez le extrait de code simplifié suivant :

// 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 sortie finale est 1, 2,. Cela est dû au fait que les variables statiques dans le fichier d'en-tête sont définies plusieurs fois, et elles seront définies chaque fois qu'elles sont incluses dans un fichier .cpp. (En cas, elle sera incluse dans main.cpp et test.cpp.) Néanmoins, fondamentalement, il s'agit du même objet.

Maintenant, la question restante est : Comment implémenter un détecteur de fuites mémoire?

Implémenter un détecteur de fuites mémoire

Implémentons notre détecteur de fuites mémoire.

Depuis que nous avons déjà surchargé l'opérateur new, il est facile de penser à gérer manuellement l'allocation de mémoire et à libérer naturellement la mémoire. Si notre delete ne libère pas toute la mémoire allouée, alors une fuite mémoire se produira. Maintenant, la question est : Quelle structure devons-nous utiliser pour gérer manuellement la mémoire?

La liste doublement chaînée est une bonne option. Pour le code réel, nous ne savons pas quand nous devons allouer de la mémoire, donc une liste linéaire simple ne suffit pas, mais une structure dynamique (par exemple, une liste chaînée) sera bien plus pratique. De plus, la liste linéaire simple n'est pas non plus pratique lorsqu'il s'agit de supprimer l'objet dans le détecteur de fuites mémoire.

Ainsi, la liste doublement chaînée est notre meilleure option pour le moment :

#include <iostream>
#include <cstring>

// Définition des macros __NEW_OVERLOAD_IMPLEMENTATION__,
// elle empêche de surcharger l'opérateur `new`
#define __NEW_OVERLOAD_IMPLEMENTATION__
#include "LeakDetector.hpp"

typedef struct _MemoryList {
    struct  _MemoryList *next, *prev;
    size_t  size;       // taille de la mémoire allouée
    bool    isArray;    // est-ce un tableau alloué ou non
    char    *file;      // fichier où se situe la fuite
    unsigned int line;  // ligne où se situe la fuite
} _MemoryList;
static unsigned long _memory_allocated = 0;     // stockage de la taille de la mémoire non libérée
static _MemoryList _root = {
    &_root, &_root,     // pointeurs du premier élément tous pointant vers lui-même
    0, false,           // 0 taille de mémoire allouée, pas un tableau
    NULL, 0             // pas de fichier, ligne 0
};

unsigned int _leak_detector::callCount = 0;

Maintenant, gérons manuellement la mémoire allouée :

// allocation de mémoire à partir du début de _MemoryList
void* AllocateMemory(size_t _size, bool _array, char *_file, unsigned _line) {
    // calcul de la nouvelle taille
    size_t newSize = sizeof(_MemoryList) + _size;

    // nous ne pouvons utiliser que malloc pour l'allocation car new a déjà été surchargé.
    _MemoryList *newElem = (_MemoryList*)malloc(newSize);

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

    // enregistre l'information du fichier s'il existe
    if (_file) {
        newElem->file = (char *)malloc(strlen(_file)+1);
        strcpy(newElem->file, _file);
    }
    // enregistre le numéro de ligne
    newElem->line = _line;

    // met à jour la liste
    _root.next->prev = newElem;
    _root.next = newElem;

    // enregistre dans la taille de la mémoire non libérée
    _memory_allocated += _size;

    // renvoie la mémoire allouée
    return (char*)newElem + sizeof(_MemoryList);
}

et l'opérateur delete également :

void  DeleteMemory(void* _ptr, bool _array) {
    // revient au début de MemoryList
    _MemoryList *currentElem = (_MemoryList *)((char *)_ptr - sizeof(_MemoryList));

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

    // met à jour la liste
    currentElem->prev->next = currentElem->next;
    currentElem->next->prev = currentElem->prev;
    _memory_allocated -= currentElem->size;

    // libère la mémoire allouée pour l'information du fichier
    if (currentElem->file) free(currentElem->file);
    free(currentElem);
}

En considérant que nous avons deux façons différentes d'utiliser new et delete :

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);
}
// surcharge de l'opérateur delete
void operator delete(void *_ptr) noexcept {
    DeleteMemory(_ptr, false);
}
void operator delete[](void *_ptr) noexcept {
    DeleteMemory(_ptr, true);
}

Enfin, nous devons implémenter _leak_detector::LeakDetector et il sera appelé dans le destructeur de static _leak_detector _exit_counter. Ensuite, tous les objets alloués seront libérés. La fuite mémoire apparaît dans le cas où static _MemoryList _root n'est pas vide, et nous n'avons qu'à itérer _root pour trouver où se situe la fuite de mémoire :

unsigned int _leak_detector::LeakDetector(void) noexcept {
    unsigned int count = 0;
    // itère sur toute la liste, s'il y a une fuite mémoire,
    // alors _LeakRoot.next ne pointe jamais vers lui-même
    _MemoryList *ptr = _root.next;
    while (ptr && ptr!= &_root)
    {
        // affiche toutes les informations de fuite
        if(ptr->isArray)
            std::cout << "Fuite[] ";
        else
            std::cout << "Fuite   ";
        std::cout << ptr << ", taille de " << ptr->size;
        if (ptr->file)
            std::cout << " (localisé dans " << ptr->file << ", ligne " << ptr->line << ")";
        else
            std::cout << " (aucune information de fichier)";
        std::cout << std::endl;

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

    if (count)
        std::cout << "Existence de " << count << " comportement de fuite mémoire, "<< _memory_allocated << " octets au total." << std::endl;
    return count;
}

Compilons tout :

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

Dans main.cpp, décommentez l'include "LeakDetector.hpp". Voici la sortie attendue :

image desc

Résumé

Dans ce laboratoire, nous avons surchargé l'opérateur new en C++ et implémenté un détecteur de fuites mémoire. En fait, le code peut également être utilisé pour détecter les fuites de référence circulaire des smart pointers. Nous n'avons qu'à modifier main.cpp comme suit :

#include <memory> // utiliser smart pointer

// pas de changement au début comme précédemment dans main.cpp

// ajouter deux classes de test
class A;
class B;
class A {
public:
    std::shared_ptr<B> p;
};
class B {
public:
    std::shared_ptr<A> p;
};

int main() {

    // changement ici comme précédemment dans main.cpp

    // cas supplémentaires, utiliser smart pointer
    auto smartA = std::make_shared<A>();
    auto smartB = std::make_shared<B>();
    smartA->p = smartB;
    smartB->p = smartA;

    return 0;

}

Nos sorties finales sont :

image desc

Pour certains d'entre vous, peut-être le résultat est-il : image desc

Votre résultat peut être différent. Chacun des deux ci-dessus est correct, car votre environnement peut différer en termes de machine et de compilateur.

Résumé

  1. Fuite mémoire - Wikipédia