Prise en charge de l'allocation dynamique – Détection de fuites et schéma de conception Observateur

Notez que les équations dans ce document sont affichées à l'aide de MathJax.

Ce document est une suite à un autre article détaillant comment il est possible de surcharger new, new[], delete et delete[] dans leur déclinaison globale, dans le but par exemple de faire un suivi comptable des opérations d'allocation et de libération dynamiques de mémoire pour fins de détection de fuites. Il serait préférable de lire et de comprendre ce premier document avant d'aborder la présente.

Imaginons que nous ayons un singleton responsable de la comptabilité de la mémoire allouée dynamiquement. Cet objet est en mesure d'offrir un certain nombre de services intéressants à un programme, mais il est impossible de prévoir a priori tous les usages envisageables par tous les programmes possibles.

On pourrait aussi vouloir suivre les demandes d'allocation ou de libération de mémoire pour tracer un graphe, tirer des statistiques quant aux patrons de comportement des programmes, essayer de calibrer nos mécanismes maison de gestion de la mémoire allouée dynamiquement, etc.

Il est possible, par exemple, qu'un éventuel programme désire vérifier si certaines allocations dynamiques de mémoire dépassent un certain seuil. Si un programme est conçu de manière telle qu'il ne soit pas supposé d'y apparaître de requêtes d'allocation dynamique de mémoire dépassant 1 Ko, alors on voudrait pouvoir réagir aux demandes d'allocation dynamique de mémoire et détecter celles qui ne nous conviennent pas.

En acceptant que nous ne pouvons pas prévoir au préalable tous les besoins éventuels de tous les programmes sujets à être clients de nos serveurs, il nous faut examiner des stratégies pour que le code client puisse s'abonner aux services de notre serveur et être informé de l'occurrence d'événements lui semblant pertinents.

Le schéma de conception Observateur

Le schéma de conception Observateur remplit ce mandat. Nous verrons ici comment il serait possible de l'appliquer à notre système de comptabilité.

Son objectif est de permettre à des objets clients de s'abonner pour être informés lors de l'occurrence d'événements dans un objet serveur.

Ses éléments clés sont :

Les dangers du schéma de conception Observateur tiennent de la relation organique entre serveur et clients. Le serveur rappelant les clients, ses performances sont a priori dépendantes de la qualité du code client dans les méthodes rappelées.

Modifications apportées à Allocation.h

Les interfaces qui permettront à un serveur de rappeler ses clients peuvent être déclarées de plusieurs manières, mais l'idée de base est qu'elles doivent permettre à un serveur de signaler des événements à un client.

Ici, le choix fait est de déclarer l'interface de rappel Rappelable à même la classe ComptableMemoire. Les méthodes que pourra implémenter un abonné (donc un dérivé de ComptableMemoire::Rappelable) seront sur_allocation(), que le serveur invoquera pour chaque abonné lorsqu'il s'apercevra d'une allocation dynamique de mémoire, et sur_deallocation() qui sera invoquée sur chaque abonné lorsque le serveur prendra conscience d'une libération de mémoire allouée dynamiquement.

#ifndef ALLOCATION_H
#define ALLOCATION_H

#include <vector>
#include <algorithm>
#include <cassert>

void* operator new(std::size_t);
void* operator new[](std::size_t);
void operator delete(void*);
void operator delete[](void*);

class ComptableMemoire {
public:
   ComptableMemoire(const ComptableMemoire&) = delete;
   ComptableMemoire& operator=(const ComptableMemoire&) = delete;
   using value_type = long long;
   struct Rappelable {
      virtual void sur_allocation(size_t) {
      }
      virtual void sur_deallocation(size_t) {
      }
      virtual ~Rappelable() = default;
   };

Remarquez la présence d'un destructeur virtuel dans l'interface ComptableMemoire::Rappelable. Comme il se doit avec des classes à vocation polymorphique (donc exposant au moins une méthode virtuelle), donc sujettes à servir de devanture polymorphiques pour des instances de classes en dérivant, il est sage de définir un destructeur virtuel pour assurer la bonne libération des ressources lors de l'éventuelle destruction des abonnés.

Remarquez aussi le choix fait de définir des corps par défaut pour les méthodes de cette interface plutôt que de les laisser abstraites. L'idée ici est qu'il est tout à fait envisageable qu'un dérivé de ComptableMemoire::Rappelable souhaite surveiller les allocations de mémoire mais ne soit pas préoccupé par les libérations de mémoire; ainsi, en offrant des corps par défaut, on offre aussi un comportement par défaut (ne rien faire) ce qui allège la tâche des développeurs.

En Java, l'usage est de définir une interface (que des méthodes publiques et abstraites) puis, lorsque le nombre de méthodes à définir chez les dérivés est grand, d'offrir une classe implémentant des comportements par défaut (habituellement vides) pour chacune d'entre elles.

Cette technique tient la route, mais n'est nécessaire en Java qu'en conséquence du fait que ce langage ne supporte pas l'héritage multiple. En C++, on pourrait l'implémenter sans peine mais ce serait, dans la plupart des cas, un peu superflu.

Le serveur doit se munir d'un conteneur d'abonnés – ici, un conteneur de Rappelable*.

Traditionnellement, on aura recours à des conteneurs standard tels des vecteurs ou des listes pour faciliter l'ajout ou le retrait d'abonnés à tout moment dans le conteneur (des tableaux bruts constitueraient une limite arbitraire et injustifiée dans la majorité des cas).

Notez que le comportement polymorphique est la raison de la mise en place de ce schéma de conception, ce qui explique le recours à un conteneur de pointeurs de Rappelable plutôt qu'à un conteneur de Rappelable.

Le choix d'un vecteur dans ce cas-ci est discutable; si le patron d'abonnement et de désabonnement des abonnés est imprévisible et si le nombre d'abonnés peut devenir grand, il est possible qu'une liste soit préférable dû à sa capacité d'insertion et de suppression d'un élément en temps constant.

private:
   std::vector<Rappelable *> abonnes;
   static ComptableMemoire singleton;
   value_type alloue = {};
   ComptableMemoire() = default;
   void fuite();
   void surliberation();

Mon implémentation personnelle des stratégies de rappel des abonnés reposera sur des foncteurs.

Cette stratégie est à la fois paresseuse (le code de rappel devient banal) et technique (règle générale, la performance lors des rappels sera meilleure avec des foncteurs).

Vous constaterez que ces foncteurs sont privés; ce sont des détails d'implémentation qui ne concernent pas le code client.

Pourrait-on fusionner ces deux foncteurs de rappel en un seul? Si oui, montrez comment; sinon, expliquez pourquoi.

// ...
   class RappelerAllocation {
      const size_t qte;
   public:
      RappelerAllocation(size_t qte) noexcept : qte{ qte } {
      }
      void operator()(Rappelable *p) {
         p->sur_allocation(qte);
      }
   };
   class RappelerDeallocation {
      const size_t qte;
   public:
      RappelerDeallocation(size_t qte) noexcept : qte{ qte } {
      }
      void operator()(Rappelable *p) {
         p->sur_deallocation(qte);
      }
   };

Le code d'abonnement et de désabonnement devrait être simple et reposer sur le code d'insertion et d'extraction du conteneur choisi. Ici, j'ai choisi de valider les pointeurs passés en paramètre pour m'assurer qu'ils soient non nuls.

Il n'est pas sage de compacter le vecteur à chaque désabonnement; si vous devez écrire du code dans un environnement où la vitesse d'exécution doit être élevée, n'intégrez pas de code de compaction.

public:
   void abonner(Rappelable *p) {
      assert(p);
      abonnes.push_back(p);
   }
   void desabonner(Rappelable *p) {
      using namespace std;
      assert(p);
      abonnes.erase(find(begin(abonnes), end(abonnes), p));
      // abonnes.shrink_to_fit(); // si besoin est
   }

Enfin, reste à voir comment informer les abonnés d'un événement d'allocation ou de libération dynamique de mémoire.

Ici, étant donné le recours à un conteneur standard et à des foncteurs, ce code est banal.

Remarquez quand même que la complexité des méthodes allocation() et deallocation() du serveur est passée d'un seul trait de à n==abonnes_.size(), avec le facteur caché que le coût de chaque invocation de méthode de Rappelable est impossible à connaître en général.

// ...
   void allocation(size_t n) {
      using namespace std;
      alloue += n;
      for(auto p : abonnes_)
         p->sur_allocation(n);
   }
   void deallocation(size_t n) {
      using namespace std;
      for(auto p : abonnes_)
         p->sur_deallocation(n);
      alloue -= n;
      if (combien() < 0) surliberation();
   }
   value_type combien() const noexcept {
      return alloue;
   }
   static ComptableMemoire &get() noexcept {
      return singleton;
   }
   ~ComptableMemoire() {
      if (combien()) fuite();
   }
   class TraceurLocalSimplet {
      value_type avant;
   public:
      TraceurLocalSimplet() noexcept : avant{ComptableMemoire::get().combien()} {
      }
      ~TraceurLocalSimplet();
   };
};

#endif

Mise en application

Pour utiliser un service d'abonnement, nous avons besoin d'au moins un abonné.

Dans le code client proposé ici, l'abonné sera une instance de SignalGrosseAllocation, un dérivé de ComptableMemoire::Rappelable. Cet observateur cherchera à détecter et à signaler à la console des demandes d'allocation dépassant un certain seuil.

#include "Allocation.h"
#include <iostream>
class SignalGrosseAllocation : public ComptableMemoire::Rappelable {
   const size_t seuil;
   size_t tolerance() const noexcept {
      return seuil;
   }
public:
   SignalGrosseAllocation(size_t seuil) : seuil{ seuil } {
   }
   void sur_allocation(size_t n) {
      using namespace std;
      if (n > tolerance())
         cout << "Allocation suspecte de " << n << " bytes (tolérance: "
              << tolerance() << " bytes)." << endl;
   }
};

Le programme de démonstration instancie (dynamique ou non; par principe, je vous propose ici un exemple sans allocation dynamique de mémoire) un observateur, puis l'abonne.

Pour la période où un observateur est abonné, il sera susceptible d'être rappelé en tout temps par le serveur .

int main() {
   SignalGrosseAllocation sga{ 100 };
   ComptableMemoire::get().abonner(&sga);
   {
      ComptableMemoire::TraceurLocalSimplet tr;
      int *temp = new int[5];
      int *temp2 = new int{4};
      delete temp2;
   }
   int *p = new int{3};
   int *q = new int[50];
   delete p;
   ComptableMemoire::get().desabonner(&sga);
}

En pratique, il est parfois possible de ne pas se désabonner d'un service. Dans ce programme de démonstration, toutefois, ne pas se désabonner serait dangereux : l'abonné (la variable sga) mourra à l'accolade fermante de main() mais le singleton ComptableMemoire sera quant à lui détruit beaucoup plus tard et pourrait chercher à rappeler sga après que cette dernière ait été détruite.


Valid XHTML 1.0 Transitional

CSS Valide !