Plateforme .Net : le ramasse-miettes

François DORIN

Le ramasse-miettes est au cœur de la plateforme .Net. Libérant le développeur de la gestion de la mémoire, il évite les fuites de mémoire ou les accès non autorisés. Dans ce tutoriel, nous allons découvrir en détail le fonctionnement du ramasse-miettes au sein de la plateforme .Net de Microsoft.

Commentez

- Titre : Plateforme .Net : le ramasse-miettes

- Auteur : François DORIN

- Parution : 12 août 2017

- Mise à jour : 11 janvier 2018

- Licence : Copyright ® 2017 François DORIN. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Présentation

- I-A. Un peu d'histoire…

- I-B. Le tas managé

- I-B-1. Le tas versus la pile

- I-B-2. Définition

- I-B-3. Allocation

- I-B-4. Le ramasse-miettes

- II. Les détails

- II-A. Phase de marquage

- II-A-1. Anatomie d'une allocation mémoire

- II-A-2. Phase de marquage

- II-B. Libération des objets et compactage

- II-C. Out of memory

- III. Ramasse-miettes générationnel

- IV. Objets avec destructeur

- IV-A. Objet IDisposable

- V. Les BLOB

- VI. Implémenter correctement l'interface IDisposable

- VI-A. Sans destructeur

- VI-B. Avec destructeur

- VII. Les objets épinglés

- VII-A. Présentation du problème

- VII-B. Gèle ou non-promotion

- VIII. Déclenchement du ramasse-miettes

- VIII-A. Déclenchement classique

- VIII-B. Appel à GC.Collect

- VIII-C. Windows

- VIII-D. Déchargement de AppDomain

- VIII-E. Fermeture du programme

- IX. Conclusion

- X. Références

I - Présentation

I-A - Un peu d'histoire…

Dans « The Annotated C++ Reference Manual », Margaret Ellis et Bjarne Stroustrup ont écrit :

Les programmeurs C pensent que la gestion de la mémoire est trop importante pour être laissée à un ordinateur. Les programmeurs LISP pensent que la gestion de la mémoire est trop importante pour être laissée aux utilisateurs.(1)

Quel que soit votre avis sur la question, il est indéniable de constater que dans un langage sans ramasse-miettes comme le C, la gestion de la mémoire par le programmeur est la source de deux problèmes majeurs :

- des fuites de mémoire, par oubli de la libérer une fois qu'elle n'est plus utile ;

- des accès non autorisés, par exemple en accédant à un pointeur déjà libéré.

Les accès non autorisés sont perfides : ils peuvent se manifester comme un bogue (dans le meilleur des cas) ou comme un trou de sécurité.

Et ces bogues sont d'autant plus difficiles à identifier que les conséquences, en plus d'être souvent aléatoires, sont difficilement reliables à leur cause originelle.

L'objectif d'un ramasse-miettes (Garbage Collector en anglais, ou GC) est de libérer le développeur(2) de la gestion de la mémoire. Une mémoire allouée ne sera automatiquement libérée que si elle n'est plus utilisée, ou autrement dit, pour le ramasse-miettes, si elle n'est plus référencée par le programme en cours d'exécution.

Tout de suite, on constate que les deux points problématiques de la gestion manuelle de la mémoire disparaissent :

- puisque la mémoire est automatiquement libérée dès lors qu'elle n'est plus utilisée, il n'y a plus de fuite ;

- puisque la mémoire n'est libérée que lorsqu'elle n'est plus utilisée, il n'est pas possible d'accéder à une mémoire qui aurait déjà été libérée.

C'est assez tôt que les premiers ramasse-miettes sont apparus. C'est John McCarthy, créateur du langage LISP, qui a, le premier, introduit cette notion.

Depuis, il y a eu beaucoup d'évolutions, et le ramasse-miettes a été adopté par d'autres langages, dont les plus connus sont sûrement le Java et le C#.

Les algorithmes sont aujourd'hui très performants. L'objectif de cet article est de vous présenter le fonctionnement du ramasse-miettes de la plateforme .Net.

Cet article décrit le fonctionnement du ramasse-miettes de la plateforme .Net, l'implémentation originelle de Microsoft. Il ne concerne pas les implémentations alternatives comme Mono ou .Net Core, même s'il y a de fortes chances que les principes restent les mêmes.

Plus de fuite de mémoire ne signifie pas que le besoin en mémoire d'une application ne puisse pas croître de manière déraisonnable. Mais dans ce cas, il s'agira d'une mauvaise gestion (trop d'allocation par exemple) et non un oubli de libération de mémoire.

I-B - Le tas managé

I-B-1 - Le tas versus la pile

Pour un programme .Net, la mémoire peut être allouée :

- soit sur le tas ;

- soit sur la pile.

Lorsque c'est possible, la mémoire est allouée sur la pile. L'allocation et la libération de la mémoire se font très simplement et très rapidement. Ce type d'allocation convient très bien pour les variables locales de type « valeur ».

Lorsque la mémoire ne peut pas être allouée sur la pile (parce qu'il s'agit d'un type référence, d'une variable locale utilisée dans une closure, etc.), la mémoire est allouée sur le tas.

C'est justement cette mémoire que le ramasse-miettes gère : le tas.

I-B-2 - Définition

Du coup, qu'est-ce que le tas ? Il s'agit d'une zone mémoire, allouée au démarrage de l'application, que le CLR(3) (ou Common Language Runtime) peut utiliser lorsqu'il a besoin d'allouer de la mémoire pour un objet.

Cette zone mémoire est propre à chaque processus. Cela signifie que tous les threads d'un processus utilisent le même tas. En cas de besoin, le CLR peut augmenter ou diminuer la taille du tas.

Pour réserver la zone mémoire qui constituera le tas managé, le CLR fait appel à la méthode Win32 En VirtualAlloc.

Il ne faut pas confondre le tas managé, géré par le CLR, et le tas natif, géré par le système d'exploitation. Cet article ne traite que du tas managé. Aussi, en l'absence de précision, le terme « tas » désigne le tas managé.

I-B-3 - Allocation

Lorsque le CLR a besoin d'allouer de la mémoire, par exemple, lorsqu'il rencontre l'opérateur new, il réserve l'espace nécessaire sur le tas. Pour cela, le CLR dispose d'un pointeur (NextObjPtr) qui indique l'espace où sera effectuée la prochaine allocation.

Au début du programme, ce pointeur est initialisé au début du tas. Et à chaque allocation, il est mis à jour et décalé.

Figure 1 : Allocation de mémoire par le CLR

Ainsi, l'algorithme d'allocation de mémoire est très simple et se déroule en temps constant, sans perdre de son efficacité au fil du temps.

I-B-4 - Le ramasse-miettes

Néanmoins, il subsiste un problème : cet algorithme suppose que la mémoire disponible est illimitée. Ce qui n'est bien entendu pas le cas.

C'est là que rentre en jeu le ramasse-miettes. Lorsqu'il n'y a plus de mémoire disponible (nous verrons un peu plus loin ce que cela signifie exactement), le ramasse-miettes se déclenche et va libérer de la mémoire :

- tout d'abord, lors du déclenchement du ramasse-miettes, le CLR suspens tous les threads du processus, afin d'éviter que les objets soient accédés et surtout modifiés durant ce processus ;

- ensuite, vient la phase de marquage, où le CLR détermine quels sont les objets qui ne sont plus utilisés et dont la mémoire peut être libérée ;

- le CLR libère effectivement les objets ;

- le CLR procède ensuite à une phase de compactage, qui consiste à déplacer les objets afin que l'espace libre ne soit plus fragmenté ;

- les threads sont réactivés. Le programme reprend alors son exécution normale.

Ce sont les grandes lignes du fonctionnement du ramasse-miettes. Dans les sections suivantes, nous allons approfondir chaque étape pour voir comment cela se passe exactement.

II - Les détails

II-A - Phase de marquage

II-A-1 - Anatomie d'une allocation mémoire

Pour bien comprendre la phase de marquage, revenons sur le fonctionnement d'une allocation mémoire. Lorsque le CLR rencontre l'opérateur new, voici ce qui se passe :

1. le CLR détermine le nombre d'octets x nécessaires pour l'objet à allouer ;

2. le CLR ajoute deux champs supplémentaires, de type pointeur, à l'objet :

- un premier, appelé « sync block index », qui sera utilisé par le ramasse-miettes,

- le second, « method table pointer », qui est une référence vers le type de l'objet ;

3. le nombre d'octets réservés xres sera donc :

- x + 8 (pour des architectures 32 bits),

- x + 16 (pour des architectures 64 bits) ;

4. l'adresse de l'objet sera l'adresse contenue dans NextObjPtr ;

5. les xres octets situés à l'adresse NextObjPtr sont initialisés à zéro ;

6. NextObjPtr est ensuite incrémenté du nombre d'octets réservés afin de pointer vers le prochain espace libre ;

7. le constructeur du type est ensuite appelé.

Figure 2 : Anatomie d'une allocation

Première constatation, l'allocation d'une zone mémoire est très simple, puisqu'il s'agit simplement de décaler un pointeur et d'initialiser une zone mémoire à zéro.

Le deuxième constat est connu sous le nom de Fr principe de localité. Pratiquement, dans la majorité des applications, des objets alloués dans un même bloc de code ont tendance à être plus ou moins liés entre eux, et donc à être accédés conjointement. Cet algorithme, en allouant des zones contiguës, respecte ce principe.

Par exemple, il est fréquent d'instancier un FileStream en même temps qu'un TextWriter ou d'un BinaryWriter afin d'accéder à un fichier.

II-A-2 - Phase de marquage

La phase de marquage consiste à distinguer les objets encore utilisés de ceux qui ne le sont plus. Pour cela, le ramasse-miettes utilise un des deux champs ajoutés à chaque objet : le « sync bloc index ». Ce champ, dont la description complète sort du cadre de ce tutoriel, contient un bit qui est utilisé par le ramasse-miettes lors du processus :

- le bit est initialisé à 1 si l'objet est utilisé (un objet utilisé est un objet encore accessible par le programme en cours d'exécution) ;

- le bit est initialisé à 0 dans le cas contraire.

Pour arriver à cela, la première étape est d'initialiser le bit correspondant à 0 pour tous les objets.

Ensuite, le ramasse-miettes va explorer la hiérarchie des objets, en commençant par les objets dits « racines ». Les objets racines sont ceux qui se situent :

- sur la pile d'appel au moment de l'exécution du ramasse-miettes ;

- les objets de classes (marqué du mot-clé static en C#).

Pour chacun de ces objets, le bit correspondant est initialisé à 1. Ensuite, chaque objet est inspecté afin de trouver des références à d'autres objets. Si une référence est trouvée, elle est aussi examinée, et ceci de manière récursive.

À chaque fois qu'un objet est inspecté, le ramasse-miettes vérifie l'état du bit. S'il est à 0, l'objet n'a jamais été inspecté et il faut donc procéder à son inspection. S'il est à 1, cela signifie que l'objet a déjà été inspecté, et qu'il est inutile de l'inspecter de nouveau. Cette méthode évite d'avoir une récursivité infinie dans le cas de la présence de cycles.

Une fois le processus terminé, le statut (accessible /non accessible) de tous les objets est connu. Il ne reste alors qu'à libérer les objets non accessibles.

II-B - Libération des objets et compactage

Maintenant que l'on sait distinguer quels sont les objets encore utilisés de ceux qui ne le sont plus, il reste à procéder à la récupération de l'espace alloué à ces objets.

Figure 3 : État avant la collection

Le principe derrière la libération de la mémoire est d'une étonnante simplicité : les objets encore utilisés sont déplacés en début de tas, afin de les « compacter ».

Ce compactage a deux effets bénéfiques :

- les objets encore utilisés seront contigus en mémoire, préservant ainsi le principe de localité précédemment abordé ;

- la mémoire libre est défragmentée.

Mais il y a malgré tout un inconvénient à procéder ainsi : les objets étant déplacés, il est nécessaire de mettre à jour toutes les références, afin que ces dernières pointent vers le nouvel emplacement en mémoire. Ainsi, à chaque référence d'un objet déplacé est soustrait le nombre d'octets duquel l'objet a été déplacé. Cette étape très importante et indispensable, se déroule elle-même en deux phases :

1. une première consistant en la mise à jour de toutes les références ;

2. une seconde consistant au déplacement des objets à proprement parler (compactage).

La dernière étape de cette phase est la mise à jour du pointeur NextObjPtr, pour qu'il pointe vers le premier octet de l'espace libre.

Figure 4 : Avant regroupement : l'espace libre est fragmenté

Figure 5 : Après regroupement : l'espace libre est défragmenté

Le ramassage des miettes étant terminé, l'application peut reprendre son exécution.

II-C - Out of memory

Si le CLR n'est pas en mesure de libérer suffisamment de mémoire via une collecte, alors il alloue un nouveau segment de mémoire, augmentant ainsi la quantité de mémoire consommée par l'application.

Dans l'éventualité où l'allocation d'un nouveau segment ne serait pas possible, une exception OutOfMemoryException est générée.

III - Ramasse-miettes générationnel

Dans le but d'améliorer les performances du ramasse-miettes, le processus de libération et de compactage n'est pas tout à fait implémenté tel quel, mais utilise une variante basée sur la notion de génération. C'est pour cela que l'on parle de ramasse-miettes générationnel.

Un ramasse-miettes générationnel fait les hypothèses suivantes :

- plus un objet est récent, plus sa durée de vie à de chances d'être courte ;

- plus un objet est vieux, plus sa durée de vie à de chances d'être longue ;

- compacter une portion du tas est plus rapide que compacter tout le tas.

Il s'agit d'hypothèses de travail, qui s'avèrent en pratique être valides pour une large gamme d'applications.

Comment fonctionne un ramasse-miettes générationnel ? Voici les grands principes :

- tout objet nouvellement créé est de génération 0 ;

- lors d'une collecte, tout objet survivant est promu dans la génération suivante. Ainsi, un objet de génération 0 deviendra un objet de génération 1, un objet de génération 1 deviendra un objet de génération 2, etc. ;

- une collecte ne collectera que les objets d'une génération donnée et des générations inférieures :

- une collecte de génération 0 ne collectera que les objets de génération 0 ;

- une collecte de génération 1 ne collectera que les objets de génération 0 et 1 ;

- une collecte de génération 2 ne collectera que les objets des générations 0, 1 et 2.

Le nombre de générations supporté par le framework .Net est limité. Actuellement, cette limite est fixée à 3 (générations 0, 1 et 2). La propriété GC.MaxGeneration permet de connaître le numéro de la dernière génération (pour le framework .Net, il s'agit donc de 2).

Lorsqu'un objet atteint la dernière génération, ne pouvant être promu pour passer dans la génération suivante, il reste dans cette génération, jusqu'à ce qu'il soit à son tour collecté.

Figure 6 : Principe de fonctionnement des générations

IV - Objets avec destructeur

Jusqu'à présent, nous avons considéré le cas simple où les objets n'avaient pas de destructeurs. Le processus de collecte des objets avec destructeur est légèrement différent.

Rappelons d'abord ce qu'est un destructeur.

Un destructeur est une méthode qui est appelée pour détruire un objet, et notamment libérer les ressources (généralement, des ressources natives comme une connexion à une base de données par exemple).

Le ramasse-miettes, avant de libérer un objet disposant d'un destructeur, doit donc appeler ce destructeur.

Le terme de destructeur est propre au langage C#. Au niveau du CLR en général, et de VB.Net en particulier, on retrouvera cette notion sous le terme de finalizer. Mais il s'agit bel et bien du même concept dans les deux cas.

Figure 7 : État avant collecte

Pour cela, après la phase de marquage, le ramasse-miettes ausculte une de ses structures internes appelée « Finalization list ». Cette liste contient une référence pour chaque objet disposant d'un destructeur. Si un objet de la liste est marqué comme étant bon à être collecté, alors il est retiré de la liste et est placé dans une autre structure interne au ramasse-miettes : une file appelée « F-reachable queue ».

Mais plus important encore, le bit indiquant si cet objet est utilisé ou non (celui qui est initialisé durant la phase de marquage), est initialisé à 1. Ainsi, cet objet ne sera finalement pas… collecté ! On dit que cet objet est « ressuscité ». Bien évidemment, tous les objets référencés par cet objet le sont également, et ceci de manière récursive.

Figure 8 : L'objet B disposant d'un destructeur, il est placé dans la file F-reachable

La phase de collecte se déroule ensuite normalement.

Figure 9 : La collecte continue normalement sur les objets marqués comme collectables

Il ne reste maintenant qu'à appeler le destructeur des objets concernés. La file spéciale « F-reachable queue » est parcourue. Et au fur et à mesure que les objets sont traités, ils sont retirés.

Figure 10 : L'objet B est retiré de la file « F-reachable » après appel de son destructeur

Actuellement, les destructeurs sont appelés depuis un thread dédié à cet usage, géré par le ramasse-miettes. Si pour l'instant il n'y a qu'un seul thread qui s'occupe de cela, il n'est pas exclu qu'à l'avenir, l'appel des destructeurs soit réalisé depuis plusieurs threads, afin d'accélérer le processus de finalisation.

Il faut être prudent lors de l'écriture d'un destructeur. Il doit s'agir de simplement libérer les ressources utilisées. Notamment, il ne faut faire aucune hypothèse quant à l'ordre dans lequel les objets seront détruits.

L'appel au destructeur se faisant depuis un thread dédié, il ne faut jamais accéder à un espace lié à un thread (par exemple, un attribut de classe décoré via l'attribut [ThreadStatic]) car les résultats ne seront pas ceux escomptés !

Ce mode de fonctionnement n'est pas sans conséquence. Un objet avec destructeur n'est pas collecté lors de la collecte par le ramasse-miettes (n'oubliez pas, il a été ressuscité). Il faudra attendre une deuxième collecte pour qu'il soit effectivement collecté.

Lors de cette seconde collecte, cet objet se comporte alors comme un objet « classique », c'est-à-dire sans destructeur, dans la mesure où il n'est plus référencé par la structure interne « Finalization list ».

Figure 11 : Lors de la prochaine collecte, B est à nouveau marqué comme étant collectable

Figure 12 : Mais B n'étant plus dans la « Finalization list » il est collecté

Le paradoxe de ce système est qu'un objet avec destructeur est promu à la génération suivante lors de la première collecte, au lieu d'être simplement collecté et l'espace mémoire libéré.

On peut donc légitimement se poser la question de savoir pourquoi cela se passe ainsi ? Pourquoi l'objet n'est-il pas simplement détruit pendant la première collecte au lieu de nécessiter une seconde collecte ? La réponse est en fait très simple : comme dit précédemment, l'appel au destructeur se fait depuis un thread dédié géré par le ramasse-miettes. Mais cet appel se fait en dehors du processus de collecte, c'est-à-dire que l'exécution de votre programme a repris.

Ainsi, on minimise le temps nécessaire pour réaliser une collecte. L'inconvénient est que pour ces objets, il faut deux collectes pour effectivement libérer la mémoire.

IV-A - Objet IDisposable

Un patron de conception que l'on retrouve fréquemment pour les objets implémentant l'interface est, d'appeler, au niveau de l'implémentation de la méthode Dispose(), la méthode GC.SuppressFinalize.

Appel à la méthode GC.SuppressFinalize

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

L'objectif de cette méthode est de préciser au ramasse-miettes de ne pas appeler le destructeur sur l'objet en question (il est donc retiré de la « finalization list »). Ainsi, lorsque l'objet est correctement libéré via l'appel à la méthode Dispose(), on libère toutes les ressources liées. Il devient donc inutile de maintenir l'appel au destructeur puisque les ressources auront déjà été libérées.

L'avantage de ce patron est qu'un objet correctement géré est donc libéré dès que possible, sans avoir à subir une double collecte ni être promu à la génération suivante.

L'inconvénient est que cela nécessite une connaissance approfondie du fonctionnement du ramasse-miettes et plus particulièrement de son implémentation.

V - Les BLOB

Les objets de grande taille (Binary Large Object, ou BLOB pour les intimes) sont également gérés d'une manière différente.

La raison à cela est que lors d'une collecte, les objets sont regroupés en mémoire, ce qui induit un déplacement. Si déplacer un entier est rapide, déplacer des objets larges peut se révéler coûteux. C'est pourquoi ces objets sont traités de manières différentes.

Au moment de l'écriture de cet article, la dernière version du framework .Net est la 4.7. Avec cette version du framework les objets de grande taille sont les objets nécessitant plus de 85 000 octets en mémoire. Cette limite est propre à l'implémentation du ramasse-miettes et même si elle n'a pas évolué par le passé, rien n'indique que cela ne sera pas le cas à l'avenir.

Aussi, comme le regroupement des objets de grande taille en mémoire serait trop pénalisant d'un point de vue des performances, il a été décidé qu'ils ne seraient pas déplacés lors du processus de collecte.

Il a donc fallu adapter légèrement le processus pour ces objets.

Première différence, tous les objets de grande taille sont de génération 2. Impact immédiat : la récupération de l'espace mémoire d'un objet de grande taille ne pourra donc se produire qu'au moment où il y aura une collecte complète. Une collecte des générations 0 ou 1 n'aura aucun impact.

Deuxième différence, les objets de grande taille sont alloués sur un tas dédié à cet effet. Ceci afin d'éviter la fragmentation de la mémoire au niveau du tas « classique » à cause de la présence d'objets de grande taille.

Depuis le framework .Net 4.5.1, il est possible de préciser que l'on souhaite regrouper les objets de grande taille. Pour cela, il faut initialiser la propriété GCSettings.LargeObjectHeapCompactionMode et l'initialiser à CompactOnce.

Ainsi, lors de la prochaine collecte, les objets de grande taille seront aussi regroupés, défragmentant le tas des objets volumineux. Il faut noter qu'après la collecte, cette propriété retrouve sa valeur Default. Il faudra donc réinitialiser explicitement à CompactOnce si on souhaite à nouveau défragmenter le tas des objets de grande taille.

VI - Implémenter correctement l'interface IDisposable

Implémenter correctement et efficacement l'interface IDisposable peut paraître compliqué au premier abord, mais lorsqu'on a bien compris le fonctionnement du ramasse-miettes, cela prend une certaine logique.

La première des questions à se poser est : faut-il ou pas un destructeur pour une classe implémentant IDisposable ?

La réponse est… tout dépend !

Si votre classe implémente l'interface IDisposable car elle manipule des objets qui implémentent eux-mêmes l'interface IDisposable, alors la réponse est non.

Si votre classe implémente l'interface IDisposable car elle manipule des ressources natives, la réponse est oui.

VI-A - Sans destructeur

public class Resource : IDisposable
{
private AnotherResource managedResource = new AnotherResource();

public void Dispose()
{
if (managedResource != null)
{
managedResource.Dispose();
managedResource = null;
}
}
}

Ici, l'implémentation est très simple : on se contente de supprimer toutes références à des objets éventuellement maintenus.

Dans le cas où des objets proposent une méthode Dispose(), on appelle cette méthode afin de libérer correctement les ressources associées.

Dans le cas particulier où une classe ne gère que des ressources managées, il est totalement inutile d'avoir un destructeur. Le destructeur ne permet que de libérer des ressources natives directement possédées par l'objet en question.

VI-B - Avec destructeur

public class Resource : IDisposable
{
private IntPtr nativeResource = Marshal.AllocHGlobal(100);
private AnotherResource managedResource = new AnotherResource();

// Dispose() appelle Dispose(true)
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
// NOTE: Supprimez le destructeur si cette classe ne
// possède aucune ressource native, mais laissez les
// autres méthodes telles quelles.
~Resource()
{
// Le destructeur appelle Dispose(false)
Dispose(false);
}
// La logique de nettoyage est implémentée dans cette méthode
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
// Libération des ressources managées
if (managedResource != null)
{
managedResource.Dispose();
managedResource = null;
}
}
// Libération des ressources natives
if (nativeResource != IntPtr.Zero)
{
Marshal.FreeHGlobal(nativeResource);
nativeResource = IntPtr.Zero;
}
}
}

Comme nous pouvons le constater, l'implémentation(4) est légèrement plus compliquée. Regardons tout en détail afin d'en comprendre le fonctionnement.

Tout d'abord, l'appel à la méthode Dispose() ou au destructeur a le même objectif : la libération des ressources. C'est donc tout naturellement que ces deux méthodes en appellent une troisième, dédiée à la libération effective des ressources, dans le but d'éviter une duplication de code. Cette troisième méthode est généralement une surcharge de la méthode Dispose, mais acceptant un paramètre disposing de type booléen.

Comme la méthode Dispose() et le destructeur ont le même but, mais agissent de manière différente (la méthode Dispose doit libérer toutes les ressources, natives et managées, tandis que le destructeur ne doit libérer que les ressources natives), il est important que cette 3e méthode puisse savoir le contexte de la libération des ressources. C'est le rôle du paramètre disposing :

- une valeur à true indique que la méthode Dispose() a été appelée (et donc qu'on libère également les ressources managées) ;

- une valeur à false indique que le destructeur a été appelé (et qu'on ne doit donc gérer que les ressources natives).

Ensuite, la surcharge de la méthode Dispose s'occupe de la libération des ressources. On voit bien ici qu'en fonction du paramètre, on libère ou non les ressources managées.

Dernier détail, la méthode Dispose() appelle la méthode GC.SupresseFinalize. L'objectif est de préciser au ramasse-miettes qu'il ne sera pas utile d'appeler le destructeur sur cet objet dans la mesure où les ressources ont déjà été libérées. On évite ainsi le besoin de procéder à une double collecte et à la promotion à la génération supérieure.

Le destructeur est véritablement un garde-fou dans le cas d'une mauvaise gestion des ressources, qui permet de libérer malgré tout les ressources lorsque ces dernières ne sont plus référencées au sein de notre programme.

On constate ici qu'une implémentation efficace de la méthode Dispose() nécessite une connaissance approfondie du ramasse-miettes et de son implémentation. Ce qui va à l'encontre de la nature même du ramasse-miettes dont l'usage est censé être le plus transparent et le moins intrusif possible.

Certains conseillent parfois de ne pas s'occuper de la gestion des ressources (et donc de ne pas appeler la méthode Dispose()), car le destructeur est justement là pour ça. Pour ma part, je considère cela comme une hérésie qui peut conduire à bien des problèmes, allant de la surconsommation de mémoire, à l'accès impossible à des fichiers, à une pénurie de connexion à une base de données, etc.

Aussi, prenez l'habitude de toujours gérer vos ressources au mieux en les libérant, cela vous fera écrire quelques lignes supplémentaires, mais vous fera gagner énormément de temps par la suite.

VII - Les objets épinglés

Un objet épinglé (pinned en anglais) est un objet dont l'adresse mémoire est fixe. Typiquement, cela est nécessaire dès lors que du code natif manipule un pointeur sur un objet managé.

Ces objets nécessitent un traitement particulier lors d'une collecte puisqu'ils ne seront pas déplacés. Cela a un impact sur les performances dans la mesure où cela induit une fragmentation de l'espace libre.

VII-A - Présentation du problème

Figure 13 : Collecte en présence d'un objet épinglé

Que se passe-t-il lors d'une collecte en cas de présence d'un objet épinglé ? La situation est résumée par le schéma de la figure 13.

Dans ce cas, les objets sont collectés, et les objets restants sont promus à la génération suivante. C'est ainsi que l'objet F passe en génération 1. La génération 0 commence après. On constate donc qu'il y a de l'espace libre, celui entre les objets B et F, qui n'est pas utilisé. Il en résulte une perte de mémoire disponible.

Soyez rassuré, cette perte n'est pas forcément permanente. Si l'objet n'est plus épinglé, ou s'il est candidat à une collecte, cet espace pourra être récupéré lors de la collecte.

Mais cela met donc en évidence un problème qui peut survenir s'il y a beaucoup d'objets épinglés : fragmentation de la mémoire libre, et inutilisable immédiatement.

Les deux facteurs accentuant ce mécanisme sont :

- le nombre d'objets épinglés (plus il y en a, plus la mémoire libre sera fragmentée) ;

- la durée pendant laquelle un objet est marqué comme épinglé (plus elle est longue, plus le ramasse-miettes à des chances de se déclencher et donc de fragmenter la mémoire libre).

En règle générale, on veillera donc à limiter le nombre d'objets épinglés, et la durée pendant laquelle ils le seront.

VII-B - Gèle ou non-promotion

Heureusement pour nous, le ramasse-miettes dispose d'un mécanisme permettant de limiter ce problème de fragmentation. Le principe, connu sous le terme de « non-promotion »(5) (demotion en anglais) est illustré figure 14.

Figure 14 : Objet épinglé sans promotion

L'objet F, après la collecte, au lieu d'être promu à la génération 1 reste en génération 0. Cela permet de faire débuter la génération 0 juste après l'objet B au lieu de l'objet F comme dans le cas précédent. Ce mécanisme permet ainsi de récupérer une partie de l'espace libre situé entre les objets B et F : tant qu'il est possible d'utiliser cet espace pour allouer de nouveaux objets, il est utilisé.

L'œil averti remarquera que le principe de localité n'est pas respecté dans ce cas de figure. Tout d'abord, pour l'objet F. Mais le principe n'était déjà plus respecté au moment même où l'objet a été épinglé.

Ensuite, pour des objets qui seraient alloués avant et après F. Mais c'est une rupture mineure n'ayant qu'un impact très marginal en comparaison des avantages qui eux, sont tangibles.

Je ne peux garantir l'exactitude des informations présentes dans ce paragraphe. Je n'ai trouvé qu'un seul article mentionnant ce principe de non-promotion, article datant de 2005 et écrit par Maoni Stephens. C'est un employé de Microsoft travaillant sur le ramasse-miettes depuis de nombreuses années. Son blog est largement orienté autour du ramasse-miettes. De ce fait, je pense que nous pouvons donc accorder un certain crédit à ses propos.

J'ai également recoupé son article avec le code source du ramasse-miettes de l'implémentation du framework CoreCLR. Sans vérifier les détails de l'implémentation, j'ai retrouvé des traces de ce principe de non-promotion. Je pense donc que l'on peut légitimement faire l'hypothèse que le ramasse-miettes implémente ce mécanisme dans une déclinaison proche de celle présentée.

Malgré le flou relatif à la gestion actuelle des objets épinglés par le ramasse-miettes, nous pouvons avoir la certitude d'un impact négatif quant aux performances du ramasse-miettes en présence de tels objets.

Il est donc recommandé d'éviter au maximum d'épingler les objets lorsque cela n'est pas nécessaire.

VIII - Déclenchement du ramasse-miettes

Afin de faire le tour sur le fonctionnement du ramasse-miettes, il est nécessaire d'aborder le sujet de son déclenchement. Cela peut se produire dans plusieurs situations différentes.

VIII-A - Déclenchement classique

Lorsque le CLR ne dispose pas de la mémoire suffisante pour instancier un objet, il lance le ramasse-miettes afin de libérer l'espace qui n'est plus utilisé. Ce déclenchement est imprévisible et peut survenir à n'importe quel moment durant la vie d'une application.

S'il n'est pas possible de déterminer quand le ramasse-miettes va se mettre à l'œuvre, il est possible d'indiquer que l'on souhaite qu'il ne se mette pas en œuvre durant une section critique (d'un point de vue temporel). Pour cela, il faut utiliser la méthode GC.TryStartNoGCRegion en début de section et GC.EndNoGCRegion en fin de section. Un appel à la méthode GC.TryStartNoGCRegion nécessite de spécifier une taille en octets. Si la méthode renvoie true, alors le ramasse-miettes ne devrait pas se mettre en marche tant que les allocations mémoire restent en deçà de la valeur précisée lors de l'appel à la méthode.

Par exemple, dans le cadre du trading à haute fréquence, où le temps de réaction se mesure en microsecondes, le déclenchement du ramasse-miettes pourrait induire un décalage de plusieurs dizaines, voire centaines de millisecondes et donc avoir des conséquences fâcheuses…

Si jamais la méthode renvoie false, alors pour éviter le déclenchement du ramasse-miettes durant la section critique, il peut être souhaitable de déclencher manuellement le ramasse-miettes via un appel à une des méthodes GC.Collect. Si le déclenchement manuel est généralement une mauvaise idée, nous sommes ici dans un cas particulier où nous savons que la pression sur la mémoire est telle que le déclenchement du ramasse-miettes est imminent, et qu'il est donc sain de le déclencher explicitement.

VIII-B - Appel à GC.Collect

Le programme rencontre un appel à GC.Collect.

Un appel sans argument force une collecte immédiate des objets.

Des surcharges avec arguments permettent de préciser les générations que l'on souhaite collecter, ainsi que le mode de collecte, c'est-à-dire est-ce que l'on force la collecte, ou est-ce qu'on indique un point de collecte, et c'est alors au ramasse-miettes de décider si oui ou non il procède effectivement à une collecte.

VIII-C - Windows

Dans le cas où le système d'exploitation reporte une mémoire disponible faible, le ramasse-miettes peut se déclencher afin de libérer de la mémoire sur le tas managé, afin de pouvoir redonner de la mémoire à Windows.

VIII-D - Déchargement de AppDomain

Lors du déchargement d'un domaine, le ramasse-miettes récupère toute la mémoire allouée à ce domaine. Tous les objets du domaine sont ainsi libérés, quelle que soit la génération à laquelle ils appartiennent.

VIII-E - Fermeture du programme

Lors de la fermeture normale d'un programme, le CLR libère toute la mémoire allouée au processus. À noter qu'ici il s'agit d'un mode dégradé de la collecte. En effet, le programme se terminant, il n'y a plus aucun objet d'utilisé. Tous les objets peuvent donc être collectés, et il est inutile de procéder à un compactage.

IX - Conclusion

Et voilà, c'est fini ! J'espère maintenant que vous comprenez un peu mieux les méandres du ramasse-miettes du framework .Net.

Si cet article cible explicitement le framework .Net historique, bon nombre des aspects abordés ici sont en réalité valides pour les autres frameworks, notamment .Net Core.

Je tiens également à remercier :

- Hinault Romaric, pour sa relecture technique ;

- gaby277, pour sa relecture technique ;

- laurent_ott, pour sa participation à la relecture technique ainsi que ses corrections orthographiques ;

- Claude Leloup pour sa relecture orthographique.

Ils ont su contribuer à l'amélioration de cet article via leurs différentes interventions.

X - Références

Pour les personnes désirant approfondir le sujet, vous trouverez ci-dessous les deux références principales ayant servi à la rédaction de cet article :

- CLR via C#, 4e édition de Jeffrey Richter ;

- la section Garbage Collection de la MSDN.

Ces deux sources d'informations, à elles seules, représentent 90 % des aspects abordés dans cet article. Elles ont été recoupées avec de nombreuses autres sources dont l'énumération complète est, de fait, inutile.

(1) C programmers think memory management is too important to be left to the computer. Lisp programmers think memory management is too important to be left to the user.

(2) Jeu de mots totalement assumé !

(3) Voir mon précédent article pour une description de ce qu'est le CLR : Présentation de la plateforme .Net

(4) Cette implémentation est issue de la MSDN, dans un article disponible à l'adresse suivante : https://msdn.microsoft.com/fr-fr/library/ms244737.aspx

(5) C'est une traduction faite par mes soins. La traduction littérale serait « rétrogradation », mais me parait inadaptée. Le principe de « demotion » repose sur la non-promotion d'un objet à la génération suivante, et non sur la rétrogradation à la génération précédente.