Allocation assistée – Partager de la mémoire entre processus

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

Il est présumé au préalable que vous êtes familière ou familier avec le schéma de conception singleton et l'idiome de programmation incopiable, de même qu'avec la programmation générique et les idiomes RAII et pImpl.

Il est possible, en C++, de spécialiser le comportement des opérateurs new et delete (et new[], et delete[]) selon plusieurs modalités. Entre autres, si le besoin d'accéder à une forme de mémoire « exotique » survient, il est possible de modéliser l'accès à cette mémoire par une classe, et de déléguer à travers elle le travail d'allocation demandé aux mécanismes plus pointus de la plateforme sous-jacente.

Survol

En survol, l'idée est d'abord de créer une classe modélisant la mémoire à accéder, et encapsulant (probablement; c'est une question de style) les mécanismes pour manipuler cette mémoire. Par exemple, à droite, nous aurions une classe permettant d'accéder à de la mémoire persistance (non-volatile), donc qui conserve son contenu en l'absence de courant électrique.

class ZoneMemoirePersistante {
   // ... mécanismes désirés...
};

Ensuite, on spécialisera les opérateurs new, new[], delete et delete[] pour ajouter des signatures acceptant une instance de cette classe en paramètre supplémentaire (après le std::size_t pour new et new[], après le void* pour delete et delete[]).

#include <new>
class ZoneMemoirePersistante; // déclaration a priori; on peut aussi
                              // inclure la déclaration de la classe
                              // si c'est préférable (à vous de voir)
// les opérateurs seront définis où bon vous semblera
void *operator new(std::size_t, ZoneMemoirePersistante&);
void *operator new[](std::size_t, ZoneMemoirePersistante&);
void operator delete(void*, ZoneMemoirePersistante&);
void operator delete[](void*, ZoneMemoirePersistante&);

Enfin, dans le code client, on instanciera la classe représentant la mémoire « exotique » en question, et on passera une référence sur cet objet à new ou à new[] en tant que paramètre supplémentaire (la syntaxe est atypique, mais le besoin l'est aussi).

Pour delete (ou delete[]), il faudra toutefois réaliser un appel explicite à operator delete (ou delete[]) pour passer ce paramètre supplémentaire, la syntaxe habituelle ne convenant pas dans ce scénario.

// ...
ZoneMemoirePersistante zmp{ /* parametres */ };
// ...
X *p = new (zmp) X{ /* parametres */ };
// ...
::operator delete(p, zmp);

Exemple détaillé

Prenons maintenant un exemple plus complet pour démontrer la mécanique et les enjeux. Notez que cet exemple est une illustration, et que dans ce type de spécialisation pointue des mécanismes de gestion de la mémoire, il y a (par définition) du cas par cas en fonction du type de mémoire visé et de la plateforme.

Mise en situation

Nous modéliserons une mémoire partagée entre un processus émetteur (ProcessusCreateur) et un ou plusieurs processus consommateurs (ProcessusAttacheur). La communication sera unidirectionnelle et directe, sans synchronisation et sans tenir compte de l'alignement en mémoire; il s'agit donc d'un exemple archi simplifié et académique (si vous souhaitez vous en inspirer pour des applications commerciales, je vous supplie : étudiez le problème en détail et soyez prudent(e) avec la synchronisation et l'alignement!)

La plateforme sous-jacente pour l'exemple sera Microsoft Windows; le code proposé utilisera des services spécifiques à cette plateforme, devra donc être adapté si vous souhaitez le transposer ailleurs.

Code client

Le code client que nous utiliserons pour cet exemple distinguera les deux processus (le reste du code sera identique de part et d'autre).

  ProcessusCreateur.cpp ProcessusAttacheur.cpp

Remarquez tout d'abord qu'outre GesMemPartagee.h, qui déclarera la classe modélisant la zone de mémoire partagée dans notre programme, tous les en-têtes inclus sont des en-têtes standards.

Sous Microsoft Windows, les zones de mémoire partagées sont modélisées par un fichier en mémoire portant un nom. J'ai utilisé le même nom (NOM_ZONE) de part et d'autre, sous la forme d'un std::string_view.

Le processus créateur (et émetteur) se distingue des processus attacheurs (et consommateurs) du fait qu'il est responsable de créer la zone partagée. Cette particularité est indiquée par un paramètre passé à la construction du gestionnaire.

L'émetteur utilise la version spécialisée de new que nous mettons en place pour écrire trois messages en mémoire partagée. Il attend ensuite qu'un usager appuie sur une touche, avant de détruire les objets préalablement construits. Pour les fin de l'exemple, nos objets partagés seront de simples messages textuels modélisés par des tableaux de char.

Le consommateur navigue à travers les blocs publiés par l'émetteur un à un par un mécanisme que j'ai nommé walkthrough(). Dans notre exemple, ceci se limitera à afficher à l'écran chaque chaîne de caractères ainsi partagée.

#include "GesMemPartagee.h"
#include <iostream>
#include <string_view>
#include <vector>

using namespace std;
constexpr auto NOM_ZONE = "filePartagee"sv;

int main() {
   using namespace std;
   GesMemPartagee ges{ NOM_ZONE, GesMemPartagee::creer };
   vector<char *> v {
      new (ges) char[7] { "J'aime" },
      new (ges) char[4] { "mon" },
      new (ges) char[5] { "prof" },
   };
   char c;
   cin.get(c);
   for (auto &p : v)
      ::operator delete[](p, ges);
}
#include "GesMemPartagee.h"
#include <iostream>
#include <string_view>
#include <algorithm>
#include <iterator>

using namespace std;
constexpr auto NOM_ZONE = "filePartagee"sv;

int main() {
   GesMemPartagee ges{ NOM_ZONE, GesMemPartagee::attacher };
   ges.walkthrough([](size_t n, char *debut, char *fin) {
      cout << "Message de taille " << n << " : ";
      copy(debut, fin, ostream_iterator{ cout });
      cout << '\n';
   });
}

Pour cet exemple, si nous lançons d'abord le processus créateur, puis un processus attacheur, l'affichage que nous obtiendrons sur ce dernier sera :

Message de taille 12 : filePartagee
Message de taille 7 : J'aime
Message de taille 4 : mon
Message de taille 5 : prof

La première ligne de cet affichage tient au fait que j'ai choisi, pour fins de débogage, d'inscrire automatiquement le nom de la zone partagée au début de la mémoire de cette zone.

Déclaration – GesMemPartagee.h

J'ai séparé le code de ces exemple en deux fichiers seulement, soit GesMemPartagee.h pour ce que j'estimais utile de rendre visible au code client et GesMemPartagee.cpp pour ce qui me semblait plus être de l'ordre de l'implémentation et du privé. Adaptez le code à vos préférences et à vos besoins, qui peuvent différer des miennes et des miens.

J'ai fait en sorte de rendre l'en-tête pleinement portable et indépendant de quelque détail spécifique à la plateforme que ce soit, comme le démontre le fait que seul des en-têtes standards y soient inclus.

#ifndef GES_MEM_PARTAGEE
#define GES_MEM_PARTAGEE

#include <cstddef>
#include <string_view>
#include <memory>

J'ai modélisé la structure à partager sous la forme d'une classe StructurePartagee, dont une instance sera logée dans l'espace de mémoire partagée. J'ai fait quelques choix d'implémentation ici :

  • Tout d'abord, j'ai choisi de représenter la mémoire partagée par un objet et d'administrer cet objet par la suite, en faisant une sorte d'aréna partagée entre plusieurs processus. Ce choix est personnel, et on aurait peu procéder autrement
  • J'ai attribué un espace de 1 MB (attribut zone) pour y placer des objets. C'est un choix arbitraire; on aurait pu être plus précis si des besoins spécifiques devaient être rencontrés
  • J'ai choisi de placer le nom de la zone partagée au début de l'aréna, mais c'est inutile et ça ne sert que pour fins de démonstration (ou de débogage)
  • J'ai fait de tous les membres d'une instance de StructurePartagee des membres privés, et de faire de la classe GesMemPartagee une amie de StructurePartagee. Exprimé autrement, le code client (autre que GesMemPartagee) ne sait à peu près rien de cette classe
  • J'ai choisi d'allouer les objets de manière contiguë dans la zone de mémoire administrée, sans tenir compte de l'alignement (ce qui rend ce type inutilisable pour des fins commerciales); vous pouvez bien sûr ajuster le design si tel est votre souhait
  • J'ai aussi choisi de précéder chaque objet de sa taille en bytes, pour permettre de naviguer plus aisément le contenu de la mémoire (voir la méthode générique walkthrough<F>(F f) qui applique à f chaque élément entreposé en mémoire – taille, pointeur sur le début, pointeur sur la fin pour former une séquence à demi ouverte)

Sur le plan technique, ce qui est le plus important à noter est que la variable entreposant la position où se fera la prochaine allocation (attribut cur) est un indice, pas un pointeur. La raison est que les pointeurs sont des adresses, et que les adresses ont un sens local à chaque processus.

Ainsi, si cur était un pointeur et si un des deux processus y écrivait une valeur, l'adresse ainsi représentée n'aurait pas le même sens pour l'autre processus.

Les indices n'ont pas cette propriété : même si &zone[cur] est une adresse distincte pour chaque processus, elle mènera au même endroit en mémoire.

class StructurePartagee {
   enum { TAILLE_ARENA = 1'024 * 1'024 };
   // inserez les guidis a placer en memoire partagee ici
   char zone[TAILLE_ARENA]{};
   StructurePartagee(std::string_view nom);
   void *allouer(std::size_t);
   template <class F>
      void walkthrough(F f) {
         for (auto p = byte_address(0), end = byte_address(cur);
              p != end; ) {
            auto taille = *reinterpret_cast<size_t *>(p);
            f(taille, p + sizeof taille, p + taille + sizeof taille);
            p += taille + sizeof taille;
         }
      }
   void *raw_address(std::size_t n) {
      return &zone[n];
   }
   char *byte_address(std::size_t n) {
      return static_cast<char *>(raw_address(n));
   }
   friend class GesMemPartagee;
   std::size_t cur{};
};

La classe GesMemPartagee que j'ai mise en place ici applique l'idiome pImpl pour éviter d'exposer des détails propres à la plateforme. Ceci a deux grandes vertus : isoler le code non-portable du code client, et accélérer les temps de compilation en réduisant la complexité du fichier d'en-tête.

Je conserve un StructurePartagee* dans chaque GesMemPartagee. En pratique, le pointé sera logé dans la mémoire partagée et servira d'aréna (les modalités pour y arriver dépendront de la plateforme).

J'ai offert deux constructeurs : un pour créer la zone partagée et un autre pour s'y attacher. Ceci permet à chaque processus de choisir son rôle dans le système (en fait, un même processus pourrait jouer plusieurs rôles avec des instances distinctes de GesMemPartagee).

class GesMemPartagee {
   struct Impl;
   std::unique_ptr<Impl> p;
   StructurePartagee *sp{};
public:
   template <class F>
      void walkthrough(F f) {
         sp->walkthrough(f);
      }
   class creer_t {};
   class attacher_t {};
   static constexpr auto creer = creer_t{};
   static constexpr auto attacher = attacher_t{};
   GesMemPartagee(std::string_view nom_zone, creer_t);
   GesMemPartagee(std::string_view nom_zone, attacher_t);
   void *allouer(std::size_t);
   void liberer(void *);
   ~GesMemPartagee();
};

Les versions spécialisées de new / new[] et delete / delete[] conçues pour allocation assistée à travers un GesMemPartagee suivent, évidemment. C'est le mécanisme que nous examinons ici, mais en pratique il ne s'agit que de la point de l'iceberg.

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

#endif

Définition – GesMemPartagee.cpp

Entrons maintenant dans les détails de l'implémentation, évidemment moins portables et moins neutres que ne le sont les détails de l'interface (décrits précédemment).

En premier lieu, j'essaie de me limiter le plus possible à des en-têtes standards et à du code portable. Quand le besoin s'en fera sentir (un peu plus loin), j'inclurai les outils de la plateforme visée pour cette implémentation.

Notez les directives using faites à la pièce plutôt qu'en bloc (pas de using pour std en entier ici). Il se trouve qu'en incluant des en-têtes de plateforme, les probabilités d'avoir des conflits de noms sont plus élevées – en particulier, C++ offre depuis C++ 17 ou type std::byte, et la plupart des systèmes d'exploitation exposent un type de ce nom à même leur API, et ce souvent sous forme de macro, ce qui est... déplaisant.

#include "GesMemPartagee.h"
#include <string>
#include <string_view>
#include <algorithm>
#include <iterator>
#include <new>
#include <variant>
#include <memory>
using std::string_view;
using std::wstring;
using std::copy;
using std::begin;
using std::end;
using std::bad_alloc;
using std::variant;
using std::in_place_type;

Je place dans la StructurePartagee le nom qui lui a été attribué, pour faciliter le débogage.

La fonction allouer(n) réserve un bloc de n + sizeof n bytes dans la zone partagée. Notez que cette implémentation simpliste ne tient pas compte des enjeux d'alignement en mémoire, nous en avons un exemple clair ici : n n'est pas aligné sur alignof(size_t), et ret n'est pas aligné sur alignof(max_align_t).

Je loge la valeur de n par allocation positionnelle au début du bloc alloué, pour faciliter le parcours de la mémoire partagée par la suite.

StructurePartagee::StructurePartagee(string_view nom) {
   auto p = static_cast<char *>(allouer(nom.size()));
   copy(begin(nom), end(nom), p);
}
void *StructurePartagee::allouer(size_t n) {
   if (cur + n + sizeof n > std::size(zone)) throw bad_alloc{};
   new (&zone[cur]) size_t{ n };
   void *ret = &zone[cur + sizeof n];
   cur += n + sizeof n;
   return ret;
}

On entre ensuite dans la mécanique non-portable associée aux fonctions du système d'exploitation.

Sous Microsoft Windows, les ressources sont représentées par des HANDLE, et chacune doit être libérée par un appel à CloseHandle(). J'ai chargé un type RAII nommé HandleOwner d'assurer la saine finalisation de cette ressource.

Pour simplifier la logique du reste du code, j'ai modélisé les erreurs de création et de connexion à la mémoire partagée par des types dont les instances sont destinées à représenter des cas d'exceptions.

#include <windows.h>
#pragma comment(lib, "user32.lib")
struct CreateFileMappingException {
   DWORD error;
};
struct MapViewOfFileException {
   DWORD error;
};
struct HandleOwner {
   HANDLE h;
   HandleOwner(const HandleOwner &) = delete;
   HandleOwner &operator=(const HandleOwner &) = delete;
   constexpr HandleOwner(HANDLE h) noexcept : h{ h } {
   }
   ~HandleOwner() {
      CloseHandle(h);
   }
};

J'ai représenté les opérations de création de la zone partagée et de connexion à cette zone par des classes. Cela simplifie la gestion des ressources et localise les opérations de part et d'autre.

La première est CreateurZone. Elle représente une zone de mémoire partagée nommée (paramètre nom passé à la construction), et déposera dans cette zone partagée une instance de ce que retournera mapper (passé aussi en paramètre à la construction) par voie de new positionnel.

Petite manoeuvre : pour finaliser la zone mémoire, je crée une fonction cleaner(p) qui appliquera le destructeur sur l'objet placé en mémoire partagée. Du fait que le type de ce que retourne mapper ne fait pas partie du type de CreateurZone, je crée un cleaner<T> à la construction et j'y mémorise le type T en question. Ainsi, je peux « oublier » ce type et finaliser tout de même la zone mémoire à la destruction.

La méthode zone() retourne un pointeur sur la zone partagée.

class CreateurZone {
   HandleOwner handleOwner;
   void *mapView;
   void (*cleanup)(void *);
   template <class T>
      static void cleaner(void *p) {
         static_cast<T *>(p)->~T();
      }
public:
   template <class F>
   CreateurZone(const std::wstring &nom, F mapper)
      : handleOwner{ CreateFileMapping(
         INVALID_HANDLE_VALUE,    // use paging file
         NULL,                    // default security
         PAGE_READWRITE,          // read/write access
         0,                       // maximum object size (high-order DWORD)
         sizeof(StructurePartagee),                // maximum object size (low-order DWORD)
         nom.data())               // name of mapping object
   } {
      using type = decltype(mapper());
      if (!handleOwner.h)
         throw CreateFileMappingException{ GetLastError() };
      // accès lecture / écriture
      mapView = MapViewOfFile(handleOwner.h, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(type));
      if (!mapView) throw MapViewOfFileException{ GetLastError() };
      new (mapView) type(mapper());
      cleanup = &cleaner<type>;
   }
   ~CreateurZone() {
      cleanup(mapView);
      UnmapViewOfFile(mapView);
   }
   void *zone() const { return mapView; }
};

La classe AttacheurZone est utilisée pour les processus qui souhaitent se connecter à une zone de mémoire partagée préalablement créée.

La mécanique impliquée est semblable à celle de CreateurZone, mais avec un peu moins de complexité du fait que la création de l'objet en zone partagée n'est pas sous sa gouverne, et qu'il en va de même pour sa finalisation.

class AttacheurZone {
   HandleOwner handleOwner;
   void *mapView;
public:
   template <class F>
   AttacheurZone(const wstring &nom, F mapper)
      : handleOwner{ OpenFileMapping(
         FILE_MAP_ALL_ACCESS,   // read/write access
         FALSE,                 // do not inherit the name
         nom.data()) }  {
      using type = decltype(mapper());
      if (!handleOwner.h)
         throw CreateFileMappingException{ GetLastError() };
      // accès lecture / écriture
      mapView = MapViewOfFile(handleOwner.h, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(type));
      if (!mapView) throw MapViewOfFileException{ GetLastError() };
   }
   ~AttacheurZone() {
      UnmapViewOfFile(mapView);
   }
   void *zone() const { return mapView; }
};

Pour réaliser le pImpl, j'utilise un type Impl contenant un variant des deux types ci-dessus, et je construis le type souhaité par voie de relais parfait.

struct GesMemPartagee::Impl {
   variant<CreateurZone, AttacheurZone> zone_op;
   template <class ... Args>
      Impl(Args &&...args) : zone_op(std::forward<Args>(args)...) {
      }
};

Pour choisir l'une ou l'autre des deux implémentations privées, j'utilise deux constructeurs de GesMemPartagee.

Une fois l'implémentation construite, je ne retiens que le pointeur sur la StructurePartagee en visitant le variant pour en tirer la zone().

GesMemPartagee::GesMemPartagee(string_view nom_zone, creer_t)
   : p{ new Impl {
        in_place_type<CreateurZone>,
        wstring{ begin(nom_zone), end(nom_zone) },
        [=] { return StructurePartagee{ nom_zone }; }
     } } {
   sp = static_cast<StructurePartagee *>(
      std::visit([](auto &&z) { return z.zone(); }, p->zone_op)
   );
}
GesMemPartagee::GesMemPartagee(string_view nom_zone, attacher_t)
   : p{ new Impl {
        in_place_type<AttacheurZone>,
        wstring{ begin(nom_zone), end(nom_zone) },
        [=] { return StructurePartagee{ nom_zone }; }
      } } {
   sp = static_cast<StructurePartagee*>(
      std::visit([](auto &&z) { return z.zone(); }, p->zone_op)
   );
}

Le reste du code est trivial :

  • Le destructeur est implicitement correct, le variant s'assurant de finaliser l'objet qui s'y loge
  • L'allocation passe par l'aréna, et (pour cet exemple simpliste) la libération est un no-op
GesMemPartagee::~GesMemPartagee() = default;
void* GesMemPartagee::allouer(size_t n) {
   return sp->allouer(n);
}
void GesMemPartagee::liberer(void *) {
}

Enfin, les opérateurs eux-mêmes délèguent le travail d'allocation assistée au type qui leur sert d'assistant.

void *operator new(size_t n, GesMemPartagee &ges) {
   return ges.allouer(n);
}
void *operator new[](size_t n, GesMemPartagee &ges) {
   return ges.allouer(n);
}
void operator delete(void *p, GesMemPartagee &ges) {
   ges.liberer(p);
}
void operator delete[](void *p, GesMemPartagee &ges) {
   ges.liberer(p);
}

Voilà. Sans que le code ne soit trivial, il demeure accessible; le code le plus subtil est celui qui interface avec le code de la plateforme, ce qui n'est pas une surprise.

En conclusion

C++ supporte une vaste quantité de mécanismes d'allocation de mémoire, et offre aux programmeuses et aux programmeurs l'occasion de contrôler ces mécanismes. Ainsi, en se retroussant les manches un peu, il devient possible d'administrer de la mémoire « exotique » presque comme si l'on allouait des objets dynamiquement selon des mécanismes plus conventionnels.


Valid XHTML 1.0 Transitional

CSS Valide !