Oui. La bonne nouvelle est que ces "pools de mémoire" sont utiles dans un certain nombre de situations.
La mauvaise nouvelle est qu'il va
falloir descendre dans le "comment cela fonctionne" avant de voir comment on l'utilise.
Si vous ne savez pas comment fonctionnent les "pools de mémoire", ce sera chose réglée bientôt.
Avant tout, il faut savoir qu'un allocateur de mémoire est supposé retourner une zone de mémoire non initialisée, il n'est pas supposé
créer des objets. En particulier, l'allocateur de mémoire n'est pas supposé mettre à jour le pointeur virtuel ou n'importe quelle autre
partie de l'objet, étant donné que c'est le travail du constructeur qui est exécuté juste après l'allocation de la mémoire. En démarrant
avec une simple fonction d'allocation de mémoire, allocate(), nous utilisons placement new pour construire un objet dans
cette mémoire. En d'autres mots, ce qui suit est moralement équivalent à new Foo() :
void * raw = allocate (sizeof (Foo));
Foo* p = new (raw) Foo ();
|
En supposant que l'on ait utilisé placement new et que l'on ait survécu au code précédent, l'étape suivante est de transformer
l'allocateur de mémoire en un objet. Ce type d'objet est appelé un pool mémoire. Cela permet aux utilisateurs d'avoir plusieurs
pools à partir desquels la mémoire peut être allouée. Chacun de ces pools mémoire allouera une grande quantité de mémoire en utilisant
un appel système spécifique (mémoire partagée, mémoire persistante, etc ....) et le distribuera en petites quantités à la demande.
Notre pool mémoire ressemblera à quelque chose de ce type :
class Pool
{
public :
void * alloc (size_t nbytes);
void dealloc (void * p);
private :
} ;
void * Pool:: alloc (size_t nbytes)
{
}
void Pool:: dealloc (void * p)
{
}
|
Maintenant, l'utilisateur devrait pouvoir obtenir un Pool (appelé pool), à partir duquel il pourra allouer des objets de la façon suivante :
Pool pool;
void * raw = pool.alloc (sizeof (Foo));
Foo* p = new (raw) Foo ();
|
ou encore :
Foo* p = new (pool.alloc (sizeof (Foo))) Foo ();
|
La raison pour laquelle il serait bon de transformer Pool en une classe est que cela permet à l'utilisateur de créer N pools mémoire
différents, plutôt que d'avoir un gros pool partagé par tous les utilisateurs. Cela permet aux utilisateurs de faire un tas de choses
plus ou moins drôles.
Par exemple, si l'on dispose de fonctions système permettant d'allouer et de libérer une énorme quantité de mémoire,
la totalité de la mémoire pourrait être allouée dans un pool, et ensuite ne faire aucun delete des allocations faites dans ce pool, pour
finalement libérer la totalité du pool en une fois.
Ou il serait possible de créer une zone de mémoire partagée (où le système
d'exploitation procure de la mémoire partagée entre différents processus) et que ce pool alloue des morceaux de mémoire partagée plutôt
que de la mémoire locale au processus.
La plupart des systèmes supportent une fonction alloca() qui alloue un bloc de mémoire sur la pile, plutôt que dans le tas. Bien
entendu, ce bloc de mémoire est libéré à la fin de la fonction, faisant disparaître le besoin de faire des delete explicites. Quelqu'un
pourrait utiliser alloca() pour attribuer au Pool sa mémoire, et que toutes les petites allocations dans ce pool agiraient comme si elles
étaient faites sur la pile : elles disparaîtraient à la fin de la fonction. Bien sûr, les destructeurs ne seraient pas appelés dans
n'importe lequel de ces cas, et si celui-ci devait faire des choses non triviales, il vous serait impossible d'utiliser ces techniques,
mais dans le cas ou le destructeur ne fait que désallouer la mémoire, ce genre de techniques peut être utiles.
Maintenant que l'on a inclus les quelques lignes de code nécessaires à l'allocation dans la classe Pool, l'étape suivante est de changer
la syntaxe d'allocation des objets.
Le but est de transformer une allocation au format inhabituel (new(pool.alloc(sizeof(Foo))))
en quelque chose de tout à fait classique (new(pool)). Pour y arriver, il faut ajouter les 2 lignes suivantes à la définition
de la classe Pool
inline void * operator new (size_t nbytes, Pool& pool)
{
return pool.alloc (nbytes);
}
|
Maintenant, lorsque le compilateur rencontrera une instruction
l'opérateur new que l'on vient de définir passera sizeof( Foo ) et pool en tant que paramètres, et la seule fonction qui
manipulera le pool sera ce nouvel opérateur new.
Passons maintenant à la destruction de l'objet Foo. Il est à noter que l'approche brutale qui est parfois utilisée avec placement
new est d'appeler explicitement le destructeur et d'ensuite désallouer la mémoire :
void sample (Pool& pool)
{
Foo* p = new (pool) Foo ();
p- > ~ Foo ();
pool.dealloc (p);
}
|
Ce code présente plusieurs problèmes, mais qui peuvent tous être réglés.
Il y aura une perte de mémoire si le constructeur lance une exception
La syntaxe de destruction/désallocation n'est pas conforme à ce que les programmeurs ont l'habitude de voir, ce qui va sûrement les
perturber fortement.
L'utilisateur doit se rappeler d'une façon ou d'une autre des associations pool/objet.
Etant donné que le code qui alloue est souvent
situé dans une autre fonction que celle qui libère, le programmeur devra manipuler deux pointeurs (un pour la classe et un pour le pool),
ce qui peut devenir rapidement indigeste (par exemple, un tableau d'objets Foo qui seraient alloués dans des pools différents)
Nous allons régler ces problèmes.
Problème n° 1 : la fuite mémoire
Quand on utilise l'opérateur new habituel, le compilateur génère un bout de code particulier pour gérer le cas ou le constructeur lance
une exception. Ce code ressemble à ceci :
principe | Foo* p;
void * raw = operator new (sizeof (Foo));
try {
p = new (raw) Foo ();
} catch (...) {
operator delete (raw);
throw ;
}
|
Le point à remarquer est que le compilateur libère la mémoire si le constructeur lance une exception. Mais dans le cas du
"new avec paramètres" (appelé communément "new avec placement"), le compilateur ne sait pas quoi faire si une exception est lancée,
il ne fait donc rien.
principe | void * raw = operator new (sizeof (Foo), pool);
Foo* p = new (raw) Foo ();
|
Le but est donc de faire faire au compilateur quelque chose de semblable à ce qu'il fait avec l'opérateur new global. Heureusement,
c'est simple : quand le compilateur rencontre
il cherche un opérateur delete correspondant. S'il en trouve un, il fait un wrapping équivalent à celui de l'appel du constructeur dans
un bloc try/catch. Nous devons juste fournir un opérateur delete avec la signature suivante. Attention de ne pas se tromper ici, car si
le second paramètre a un type différent de celui de l'opérateur new, le compilateur n'émettra aucun message, il ignorera simplement le
bloc try/catch quand l'utilisateur effectuera l'allocation.
void operator delete (void * p, Pool& pool)
{
pool.dealloc (p);
}
|
Maintenant, le compilateur intégrera automatiquement les appels au constructeur dans un bloc try/catch.
principe | Foo* p;
void * raw = operator new (sizeof (Foo), pool);
try {
p = new (raw) Foo ();
} catch (...) {
operator delete (raw, pool);
throw ;
}
|
En d'autres mots, l'ajout de l'opérateur delete avec la signature ad hoc règle automatiquement le problème de fuite de mémoire.
Problème 2 : se souvenir des associations objet/pool
Ce problème est réglé par l'ajout de quelques lignes de code à un endroit. En d'autres mots, nous allons ajouter ces lignes de code à
un endroit (le fichier header du pool), ce qui va simplifier par la même occasion un certain nombre d'appels.
L'idée est d'associer de manière implicite un Pool* avec chaque allocation. Le Pool* associé à l'allocateur global pourrait être NULL,
mais conceptuellement on peut dire que chaque allocation a un Pool* associé.
Ensuite, nous remplaçons l'opérateur delete global de façon qu'il examine le Pool* associé, et s'il est non NULL, il appellera la
fonction de libération associée. Par exemple, si le désallocateur normal utilisait free(), le remplacement pour l'opérateur delete
global ressemblerait à quelque chose comme ceci :
void operator delete (void * p)
{
if (p ! = NULL ) {
Pool* pool = ;
if (pool = = null)
free (p);
else
pool- > dealloc (p);
}
}
|
Si free() était le désallocateur normal, l'approche la plus sûre serait de remplacer aussi l'opérateur new par quelque chose qui utiliserait
malloc(). Le code remplaçant l'opérateur global new ressemblerait alors à quelque chose comme ce qui suit :
void * operator new (size_t nbytes)
{
if (nbytes = = 0 )
nbytes = 1 ;
void * raw = malloc (nbytes);
return raw;
}
|
Le dernier problème est d'associer un Pool* à une allocation. Une approche, utilisée dans au moins un produit commercial, est d'utiliser
un
En d'autres mots, il suffit de construire une table associative ou les clés sont les pointeurs alloués et les valeurs sont les Pool*
associés. Pour différentes raisons, il est essentiel que les paires clé/valeur soient insérées à partir de l'opérateur new. En
particulier, il ne faut pas insérer une paire de clé/valeur à partir de l'opérateur new global. La raison est la suivante, faire cela
créerait un problème circulaire : étant donné que std::map utilise plus que probablement l'opérateur new global, à chaque insertion d'un
élément serait appelé, pour insérer une nouvelle entrée, ce qui mène directement à une récursion infinie.
Même si cette technique exige une recherche dans le std::map à chaque libération, elle semble avoir des performances suffisantes, du moins
dans la plupart des cas.
Une autre approche, plus rapide, mais qui peut utiliser plus de mémoire, et est un peu plus complexe, est de spécifier un Pool* juste avant
toutes les allocations. Par exemple, si nbytes vaut 24, c'est-à-dire que l'appelant veut allouer 24 bytes, on alloue 28 bytes
(ou 32, si la machine aligne les doubles ou les long long sur 8 bytes), spécifie le Pool* dans les 4 premiers bytes, et retourne
le pointeur avec un décalage de 4 bytes (ou 8) suivant l'architecture.
Pour la libération du pointeur, l'opérateur delete libère
la mémoire en tenant compte du décalage de 4 (ou 8) bytes. Si Pool* vaut NULL, on utilise free(), sinon pool->dealloc().
Le paramètre passé à free() et à pool->dealloc() est le pointeur décrémente de 4 ou 8 bytes du paramètre original.
Pour un alignement de 4 bytes, le code pourrait ressembler à ceci :
void * operator new (size_t nbytes)
{
if (nbytes = = 0 )
nbytes = 1 ;
void * ans = malloc (nbytes + 4 );
* (Pool* * )ans = NULL ;
return (char * )ans + 4 ;
}
void * operator new (size_t nbytes, Pool& pool)
{
if (nbytes = = 0 )
nbytes = 1 ;
void * ans = pool.alloc (nbytes + 4 );
* (Pool* * )ans = & pool;
return (char * )ans + 4 ;
}
void operator delete (void * p)
{
if (p ! = NULL ) {
p = (char * )p - 4 ;
Pool* pool = * (Pool* * )p;
if (pool = = null)
free (p);
else
pool- > dealloc (p);
}
}
|
Naturellement, ces derniers paragraphes sont uniquement valables si on peut modifier l'opérateur new global, ainsi que delete.
S'il n'est pas possible de changer le comportement de ces opérateurs globaux, les trois quarts du texte qui précède restent valables.
|