Garbage Collector en .NET - Partie 1

2020-08-09 par Badre BSAILA

L’environnement d’exécution .NET (CLR: Common Language Runtime) possède un Garbage Collector (GC) qui s’occupe de la mémoire: il gère l’allocation et la libération des ressources pour une application. Le GC permet de:

  • Ne pas avoir à gérer manuellement la libération des adresses mémoires utilisées comme dans les langages natives (par exemple la fonction free() dans le langage C).

  • Libérer automatiquement la mémoire des objets plus utilisés et la mettre à disposition des prochaines allocations.

  • Allouer les objets automatiquement (pour s’affranchir des appels du genre malloc() en C) et efficacement.

  • Assurer qu’aucun autre programme ne vient allouer sur une plage mémoire déjà occupée.

Patrick Dussud et Maoni Stephens ont largement contribué au développement du GC .NET, sa version 1 a été faite avec LISP et converti en C++.

L’article est une 1ère partie d’une série où je vais couvrir les bases de la gestion mémoire en .NET :

  • Partie 1 - Tas managé, LOH, GC, Collection, Générations et Ségments (article courant).
  • Partie 2 - Démonstration avec windbg.
  • Partie 3 - Graphe des objets et références.
  • Partie 4 - Workstation/Server GC et arrière-plan GC.
  • Partie 5 - Compteurs de performance et événements ETW

Notions sur la mémoire

  • Chaque processus exécutant un programme possède son propre espace d’adresses virtuelles pour y allouer ses objets: un processus A ne peut pas piétiner sur l’espace d’adresses virtuelles du processus B. Par contre, tous les processus sur un ordinateur se partage la même mémoire physique sur la RAM et le même fichier page s’il existe (un fichier page est un fichier caché sur le disque dûr qui peut être utilisé comme mémoire secondaire à la RAM).

alt Mémoire virtuelle

  • Par défaut sur une architecture 32 bits, chaque processus possède 2 GB sur son propre espace d’adresses virtuelles.

  • En tant que développeur, on manipule uniquement l’espace d’adresses virtuelles mais jamais directement la mémoire physique sur la RAM ou le fichier page.

  • Une page (unité de mémoire) de la mémoire virtuelle peut être dans 3 états:

    État Description
    Libre La page n’est ni réservée ni validée. Elle est inaccessible à un processus. Une tentative de lecture/écriture sur cette dernière résulte sur une erreur d’accès non autorisé.
    Réservée La page a été réservée pour utilisation, mais ne possède pas de stockage physique associé.
    Validée Un stockage physique est alloué pour cette page. L’OS persiste les pages validées sur le stockage physique seulement durant la 1ère tentative de lecture/écriture sur cette page. Quand le processus termine, l’OS libère les pages validées
  • Un objet alloué doit convenir dans un espace mémoire contigu, par exemple: un objet de 16 bytes doit être alloué sur les cases mémoire [0-15] mais jamais on ne peut le diviser sur les 2 espaces [0-5] et [20-30].

  • L’espace d’adresses virtuelles peut devenir fragmenté suite aux allocations et libérations successives: ça veut dire qu’il devient constitué de blocs mémoire alloués non consécutivement. Le corollaire découlant est que si nous possédions 20 Mo d’espace vide, il y a des chances pour ne pas pouvoir stocker un objet de 20 Mo si l’espace disponible n’est pas contigu.

alt Mémoire fragmentée

Allocation de mémoire

A la création d’un nouveau processus .NET, le CLR réserve un espace de mémoire contigu pour ce dernier qu’on désigne par le tas managé (Managed Heap). Le tas conserve un pointeur vers l’adresse où le prochain objet sera stocké. Tous les types références (class) sont stockés sur le tas, par contre les types valeurs (struct) sont stockés sur la pile (Stack): la distinction est faîte car les accès à la Stack sont plus rapides qu’au Heap tandis que la taille des données qu’on peut y stocker est plus limitée.

Libération de la mémoire

Le GC maintient un graphe des objets alloués et les objets réferencés par ces derniers. Quand le GC décide de ‘collecter’ un peu de mémoire, il parcourt tout le graphe des objets et cherche ceux qui ne sont plus référencés. S’il découvre des objets à supprimer, il libère leurs espaces mémoire. Pour garder un espace mémoire contigu et limiter sa fragmentation, le GC compacte aussi le tas: il déplace les objets toujours en vie pour bouchonner les trous laissés par les objets supprimés, et corrige les pointeurs des objets compactés pour pointer sur les nouvelles adresses mémoire. Il déplace aussi le pointeur du Heap. Durant cette étape, le GC est obligé d’arrêter tous les threads en cours d’exécution pour ne pas les corrompre.

alt GC déclenché

Pour améliorer les performances, le GC alloue les objets volumineux sur le tas large (LOH: Large Object Heap). Il regroupe les objets de plus de 85 000 bytes (limite configurable). Par contre, pour éviter de déplacer de gros objets et causer des soucis de performance, on ne compacte pas cette région.

Plus le tas managé est petit, moins le GC intervient et moins il bloque les threads en cours d’exécution, c’est pourquoi il faut rationaliser l’instantiation quand c’est possible.

Les conditions pour collecter la mémoire

Le GC se déclenche dans ces cas:

  • L’OS notifie qu’il n’y a pas assez de mémoire physique.

  • La mémoire allouée sur le tas dépasse ce qui est acceptable. Cette limite est ajustée de façon continue durant l’exécution du processus.

  • L’appel à la méthode GC.Collect().

Générations

Le GC de .NET est un GC générationnel reposant sur les postulats suivants :

  • Il est plus rapide de compacter une partie du tas que son intégralité.

  • Les objets fraîchement créés auront probablement une durée de vie minime par rapport aux anciens.

  • Les nouveaux objets tendent à être liés entre eux et sont lus par l’application en même temps.

En conséquence, le GC collecte en priorité les objets frais pour optimiser les performances. Pour ce faire, le tas est découpé en 3 générations: 0, 1 et 2 pour gérer les objets nouveaux/anciens séparément. Le GC stocke chaque objet nouvellement créé dans la génération 0. Si l’objet survit à une collecte du GC, il est promu vers la génération 1 puis 2. Puisque c’est toujours rapide de libérer qu’une portion du tas, ce schéma permet de cibler la génération la plus opportune quand le GC tourne.

  • Génération 0: Elle contient des objets jeunes du genre variable locale d’une méthode. GC opère plus fréquemment sur cette génération. Chaque objet créé réside dans la génération 0. Par contre si les objets sont tellement volumineux pour être candidats au LOH (désigné parfois par la génération 3), ils y sont déplacés.

  • Génération 1: contient les objets qui ont survécu à la collecte de la génération 0. Ainsi le GC ne gaspille pas son temps à essayer de les libérer fréquemment car il sait qu’il y aura de fortes chances pour trouver l’objet toujours en vie.

  • Génération 2: contient les objets qui ont survécu à la collecte de la génération 1, y réside souvent des objets de longue vie par définition comme les objets statiques. Les objets de la génération 2 qui survivent à une collecte du GC restent dans la génération 2. La LOH est collectée avec la génération 2.

Si le GC ne libère pas assez de mémoire dans la génération X, il va essayer de collecter la génération X + 1, c’est pourquoi on appelle une collecte de la génération 2: une collecte complète.

Générations éphémères et ségments

Un ségment est un bloc mémoire demandé par le GC à l’OS pour stocker des données d’un processus. Au démarrage le GC demande 2 ségments: un pour le tas classique et un autre pour la LOH. Quand le GC veut étendre le tas managé, il réclame plus de ségments à l’OS et les libère s’ils ne sont plus utilisés. Les générations 0 et 1 sont considérées comme des générations éphémères car contenants des objets de courtes durées de vie. Les générations éphémères sont allouées sur des ségments dits éphémères. Chaque nouveau ségment mémoire acquis par le GC du système d’exploitation est considéré comme ségment éphémère et est utilisé pour stocker les objets de génération 0 ayant survécu à la collecte du GC. Les anciens ségments éphémères deviennent des ségments de la génération 2. Ceci dit rien n’empêche de voir un objet de la génération 2 stocké sur un ségment éphémère.

Ressources natives

Les processus ne manipulent pas uniquement des objets managés dans le tas mais aussi des handle vers des fichiers OS, des connections réseau, et des références vers des librairies C/C++/Java. Le GC a malheureusement 0 information sur comment les collecter, c’est pourquoi il est important de:

  • Implémenter IDisposable sur sa classe et l’appeler quand son objet ne sert plus à rien: permet au GC non seulement de libérer l’objet managé mais aussi de fermer les connections, libérer les handle et lock posés sur certaines ressources non-managés.

  • Ajouter un finalizer: avant de récupérer l’espace mémoire des objets morts, ces derniers sont mis sur une queue de finalisation et le GC les défile un-à un en appelant leurs propres finalizer. Ce qui permet de libérer des ressources non-managés même si le développeur oublie d’appeler Dispose().

Wrap-up

Pour résumer, la collecte par GC se déroule ainsi:

  • Parcourir le graphe des objets créés par le processus .NET et marquer ceux toujours en vie.

  • Marquer les objets morts.

  • Finaliser et libérer ces objets et compacter ceux toujours en vie. La LOH n’est pas compactée (on peut forcer l’opération avec du paramétrage).

Avant le passage du GC, tous les threads sont pausés sauf les threads du GC.

Restez branchés pour la suite :wink:.

Références


Badre BSAILA, Ingénieur d'étude et développement .NET sénior