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 traviale du constructeur qui est éxé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 des quels 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 persistente, 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:
...data members used in your pool object...
};
void* Pool::alloc(size_t nbytes)
{
...your algorithm goes here...
}
void Pool::dealloc(void* p)
{
...your algorithm goes here...
}
Maintenant, l'utilisateur devrait pouvoir obtenir un Pool (appelé pool), à partir du quel il pourra allouer des objets de la façon suivante :
Pool pool;
...
void* raw = pool.alloc(sizeof(Foo));
Foo* p = new(raw) Foo();
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 nous avions un appel système qui permet d'allouer une énorme quantité de mémoire et puis disparait,
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 processsus.
La plupart des systèmes supportent une fonction alloca() qui alloue un bloc de mémoire sur la pile, plutôt que dans le heap. Bien
entendu, ce bloc de mémoire est libéré à la fin de la fonction, faisant disparî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
etaient 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 distructeur ne fait que désaloouer la mémoire, ce genre de techniques peuvent ê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 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 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 et 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'apporche brutale qui est parfois utilisée avec placement
new est d'appeler explicitement le destrucuteur 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'objet Foo qui seraient alloués dans des pools différents)
Nous allons régler ces problèmes.
Problène 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 excpetion. Mais dans le cas du
"nex avec paramètres" (appelé communément "new avec placement"), le compilateur ne sait pas quoi faire si une excpetion est lancée,
il ne fiat 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. Hereusement,
c'est simple : quand le compilateur rencontre
il cherche un opérateur delete correspondant. Si 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. Aattention 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 qaund 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 mors, 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 nombres 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 remplacons l'operateur delete global de façon à ce qu'il examine le Pool* associé, et si il est non NULL, il appelera 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 est de remplacer aussi l'opérateur new par quelque chose qui utilise
malloc(). Le code remplacant l'operateur global new ressemblerait à quelque chose comme ce qui suit :
void* operator new(size_t nbytes)
{
if (nbytes == 0)
nbytes = 1;
void* raw = malloc(nbytes);
... associer le Pool* à Null a 'raw'...
return raw;
}
Le dernier problème est d'associer un Pool* à une allocation. Une approche, utilisée dans au moins un prodtui comemrcial, 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 dans 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 a 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 specifier un Pool* juste avant
toutes les allocations. Par exemple, si nbytes vaut 24, c'est-à-dire que l'appelant veut allouer 24 bytes, nous allouerions 28 bytes
(ou 32, si la machine aligne les doubles ou les long long sur 8 bytes), specifierions le Pool* dans les 4 premiers bytes, et retournerions
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érerait
la mémoire en tenant compte du décalage de 4 (ou 8) bytes. Si Pool* est NULL, nous utilisons free(), sinon pool->dealloc(). Le paramètre
passé à free() et à pool->dealloc() serait le pointeur décrémente de 4 ou 8 bytes du paramètre original. Si on travailler avec un
alignement de 4 bytes, le code ressemblerait à 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 3/4 du texte qui précède restent valables.
|