Prise en charge de l'allocation dynamique de mémoire – Détection de fuites

La version en début de cet article est un peu simpliste; pour une version plus complète, qui tient compte (au moins sommairement) d'alignement, de cycle de vie des objets et de synchronisation, voir ceci.

Dans le but d'apprendre comment surcharger les opérateurs d'allocation et de libération dynamique de mémoire que sont new, new[], delete et delete[], une application pratique est de mettre au point une stratégie de comptabilité de la mémoire allouée et de la mémoire libérée. En ayant accès à ces données, il devient possible de détecter certains types de fuite de ressources.

Pour faciliter la comptabilité des ressources, j'utiliserai un singleton, qui sera une classe incopiable. Le singleton en soi se nommera ComptableMemoire et se déclinera comme suit :

#ifndef ALLOCATION_H
#define ALLOCATION_H

//
// J'insérerai ici les prototypes des opérateurs
// new, new[], delete et delete[]
//

class ComptableMemoire {
public:
   using value_type = long long;
private:
   value_type qte {};
   ComptableMemoire()= default;
   void fuite();
   void surliberation();
public:
   ComptableMemoire(const ComptableMemoire&) = delete;
   ComptableMemoire& operator=(const ComptableMemoire&) = delete;
   void allocation(size_t n) {
      qte += static_cast<value_type>(n);
   }
   void deallocation(size_t n) {
      qte -= static_cast<value_type>(n);
      if (combien() < 0) surliberation();
   }
   value_type combien() const {
      return qte;
   }
   static ComptableMemoire &get() {
      static ComptableMemoire singleton;
      return singleton;
   }
   ~ComptableMemoire() {
      if (combien()) fuite();
   }
   class TraceurLocalSimplet {
      value_type avant;
   public:
      TraceurLocalSimplet() : avant{ComptableMemoire::get().combien()} {
      }
      ~TraceurLocalSimplet();
   };
};
#endif

Les méthodes clés de ComptableMemoire sont allocation(), deallocation() et combien(). Retenons que ComptableMemoire comptabilisera la mémoire allouée mais n'allouera rien par elle-même.

Notez que certains blocs ne sont pas supposés être balancés; un TraceurLocalSimplet est un outil dont on se servira pour tester l'hypothèse qu'un bloc a une stratégie équilibrée d'allocation dynamique de mémoire, tout simplement.

Remarquez la classe locale TraceurLocalSimplet. Cette classe aura un rôle tout simple :

Ceci permettra de vérifier si, dans un bloc de code donné, les opérations d'allocation et de libération dynamique de mémoire sont balancées.

Remarquez aussi que le destructeur de ComptableMemoire invoque une méthode nommée fuite() dans son destructeur dans la mesure où une fuite de mémoire a été détectée. Cette stratégie peut être risquée : un ComptableMemoire est un singleton statique et tout objet dont il pourrait se servir pour souligner la fuite détectée (incluant les flux de sortie et d'erreur standard) est susceptible d'avoir été détruit au moment de la destruction du ComptableMemoire en tant que tel.

Nous verrons plus tard une stratégie plus stable et moins dangereuse de signaler les fuites (à court terme, déclarer un TraceurLocalSimplet est beaucoup mieux).

En ajoutant les prototypes des opérateurs d'allocation et de libération dynamique de mémoires dans leur déclinaison globale, on obtient le fichier Allocation.h suivant :

#ifndef ALLOCATION_H
#define ALLOCATION_H

//
// Allocation et libération dynamique de mémoire. Surcharge
// des opérateurs globaux par une version comptabilisant ce
// qui est alloué et facilitant le traçage des fuites.
//
void* operator new(size_t);
void* operator new[](size_t);
void operator delete(void*);
void operator delete[](void*);

class ComptableMemoire {
public:
   using value_type = long long;
private:
   value_type qte {};
   ComptableMemoire()= default;
   void fuite();
   void surliberation();
public:
   ComptableMemoire(const ComptableMemoire&) = delete;
   ComptableMemoire& operator=(const ComptableMemoire&) = delete;
   void allocation(size_t n) {
      qte += static_cast<value_type>(n);
   }
   void deallocation(size_t n) {
      qte -= static_cast<value_type>(n);
      if (combien() < 0) surliberation();
   }
   value_type combien() const {
      return qte;
   }
   static ComptableMemoire &get() {
      static ComptableMemoire singleton;
      return singleton;
   }
   ~ComptableMemoire() {
      if (combien()) fuite();
   }
   class TraceurLocalSimplet {
      value_type avant;
   public:
      TraceurLocalSimplet () : avant{ComptableMemoire::get().combien()} {
      }
      ~TraceurLocalSimplet();
   };
};
#endif

Le fichier source Allocation.cpp accompagnant ces déclarations se détaille quant à lui comme suit :

#include "Allocation.h"
#include <iostream>
#include <cstdlib>
#include <new>
using namespace std;

void * operator new(size_t n) {
   //
   // Allocation d'un size_t supplémentaire à chaque new (très
   // coûteux, mais simple). Le pointeur retourné est sur l'espace
   // attribué pour les données; la taille attribuée se trouve
   // juste avant les données en mémoire. Le size_t supplémentaire
   // n'est pas comptabilisé par la mécanique de détection des
   // fuites puisqu'il s'agit d'un détail d'implémentation.
   //
   void *p = malloc(n + sizeof(size_t));
   if (!p) throw std::bad_alloc{ };
   ComptableMemoire::get().allocation(n);
   *(static_cast<size_t *>(p)) = n;
   return static_cast<size_t *>(p) + 1;
}

void *operator new[](size_t n) {
   //
   // Allocation d'un size_t supplémentaire à chaque new[] (très
   // coûteux, mais simple). Le pointeur retourné est sur l'espace
   // attribué pour les données; la taille attribuée se trouve
   // juste avant les données en mémoire. Le size_t supplémentaire
   // n'est pas comptabilisé par la mécanique de détection des
   // fuites puisqu'il s'agit d'un détail d'implémentation.
   //
   void *p = malloc(n + sizeof(size_t));
   if (!p) throw bad_alloc{};
   ComptableMemoire::get().allocation(n);
   *(static_cast<size_t *>(p)) = n;
   return static_cast<size_t *>(p) + 1;
}

void operator delete(void *p) {
   if (!p) return;
   size_t *q = static_cast<size_t *>(p) - 1;
   ComptableMemoire::get().deallocation(*q);
   free(q);
}

void operator delete[](void *p) {
   if (!p) return;
   size_t *q = static_cast<size_t *>(p) - 1;
   ComptableMemoire::get().deallocation(*q);
   free(q);
}

void ComptableMemoire::fuite() {
   cerr << "Fuite de " << combien() << " bytes\n";
}

void ComptableMemoire::surliberation() {
   cerr << "Libération abusive de " << combien() << " bytes\n";
}

ComptableMemoire::TraceurLocalSimplet::~TraceurLocalSimplet() {
   if (auto diff = ComptableMemoire::get().combien() - avant; diff) // hum...
      cerr << "Fuite locale de " << diff << " bytes" << endl;
}

Remarquez principalement que new et new[] sont identiques et que delete et delete[] le sont aussi. Ce n'est pas une loi ou une obligation; il s'avère simplement que pour ce schèma très simple d'allocation et de libération de mémoire, il n'est pas nécessaire d'être plus sophistiqué que cela.

L'essentiel du travail fait par new/ new[] et delete/ delete[] ici est :

Ce qui complique un peu la sauce est l'incontournable ensemble de conversions explicites de types appliquées aux divers pointeurs manipulés ici (de void* à size_t* et inversement).

Un programme de test suit. Le petit bloc interne allouant de la mémoire à travers les pointeurs temp et temp2 détectera une fuite de sizeof(int) bytes (sizeof(int)==4 sur ma machine) du fait que temp2 n'y est pas libérée et le programme dans son ensemble détectera une fuite de 11*sizeof(int) bytes (les dix int du tableau q et le int pointé par temp2).

#include "Allocation.h"
int main() {
   {
      ComptableMemoire::TraceurLocalSimplet tr;
      int *temp = new int[5];
      int *temp2 = new int{4};
      delete[] temp;
   }
   int *p = new int{ 3 };
   int *q = new int[10];
   delete p;
}

Version plus complète

Pour faire une version plus complète, moins risquée, il faut :

Nous couvrirons sommairement chacun de ces trois aspects ci-dessous.

Allocations concurrentes

Notre implémentation utilise, sous la couverture, les fonctions std::malloc() et std::free() qui devraient toutes deux être Thread-Safe. Toutefois, notre ComptableMemoire réalise des accès (potentiellement) concurrents sur son état qte, ce qui fait qu'en pratique, notre programme de test donne dans le comportement indéfini.

La solution la plus simple à ce problème est d'ajouter de la synchronisation sur ces accès, en faisant de l'état qte une variable atomique :

#ifndef ALLOCATION_H
#define ALLOCATION_H
#include <atomic>
// ...
class ComptableMemoire {
public:
   using value_type = long long;
private:
   std::atomic<value_type> qte {}; // <-- ICI
   // ...

Alignement

Nos manipulations positionnelles dans les opérateurs new et delete placent l'objet / les objets alloués sur une frontière de sizeof(std::size_t) bytes, ce qui peut ne pas convenir à tous les types (à moins que sizeof(std::size_t)==alignof(std::max_align_t), ce qui est plutôt rare en pratique au moment d'écrire ceci). Conséquemment, le code pourrait planter, ou mener à des risques d'accès mal alignés (chose très vilaine).

Pour corriger cette situation, sachant que std::malloc() retournera une adresse alignée de manière à convenir même aux contraintes de std::max_align_t, mieux vaut adapter le jeu de pointeurs par lequel nous dissimulons le nombre de bytes alloué :

// ...
void * operator new(size_t n) {
   void *p = malloc(n + sizeof(max_align_t)); // <-- ICI
   if (!p) throw std::bad_alloc{ };
   ComptableMemoire::get().allocation(n);
   *(static_cast<size_t *>(p)) = n;
   return static_cast<max_align_t *>(p) + 1; // <-- ICI
}
// ...
void operator delete(void *p) {
   if (!p) return;
   void *q = static_cast<max_align_t *>(p) - 1; // <-- ICI
   ComptableMemoire::get().deallocation(*static_cast<size_t*>(q)); // <-- ICI
   free(q);
}
// ...

Cycle de vie des objets

Nous avons un peu triché, dans l'opérateur new, en affectant un size_t à un bloc de mémoire brute. La manière correcte d'initialiser une zone de mémoire brute est d'utiliser un constructeur; dans le cas qui nous intéresse, cela requiert le Placement new :

// ...
void * operator new(size_t n) {
   void *p = malloc(n + sizeof(max_align_t));
   if (!p) throw std::bad_alloc{ };
   ComptableMemoire::get().allocation(n);
   new (p) size_t{ n }; // <-- ICI
   return static_cast<max_align_t *>(p) + 1;
}
// ...
void operator delete(void *p) {
   if (!p) return;
   void *q = static_cast<max_align_t *>(p) - 1;
   ComptableMemoire::get().deallocation(*static_cast<size_t*>(q));
   free(q);
}
// ...

Valid XHTML 1.0 Transitional

CSS Valide !