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.
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. |
|
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[]). |
|
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. |
|
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.
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.
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. |
|
|
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.
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. |
|
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 :
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. |
|
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). |
|
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. |
|
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. |
|
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. |
|
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. |
|
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. |
|
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. |
|
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. |
|
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(). |
|
Le reste du code est trivial :
|
|
Enfin, les opérateurs eux-mêmes délèguent le travail d'allocation assistée au type qui leur sert d'assistant. |
|
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.
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.