Gestion de la mémoire et récupération de mémoire (GC) dans ASP.NET Core

Par Sébastien Ros et Rick Anderson

La gestion de la mémoire est complexe, même dans un framework managé comme .NET. L’analyse et la compréhension des problèmes de mémoire peuvent être difficiles. Cet article :

  • Est le résultat de nombreux problèmes de fuite de mémoire et de fonctionnement de la récupération de mémoire. La plupart de ces problèmes ont été causés par un manque de compréhension du fonctionnement de la consommation de la mémoire dans .NET Core ou de comment elle est mesurée.
  • Illustre l’utilisation problématique de la mémoire et suggère d’autres approches.

Fonctionnement de la récupération de mémoire (GC) dans .NET Core

Le GC alloue des segments de tas où chaque segment est une plage contiguë de mémoire. Les objets placés dans le tas sont classés dans l’une des 3 générations suivantes : 0, 1 ou 2. La génération détermine la fréquence à laquelle le GC tente de libérer de la mémoire sur les objets managés qui ne sont plus référencés par l’application. Les générations numérotées inférieures sont plus fréquentes.

Les objets sont déplacés d’une génération à une autre en fonction de leur durée de vie. À mesure que les objets vivent plus longtemps, ils sont déplacés vers une génération supérieure. Comme mentionné précédemment, les générations supérieures sont moins fréquentes. Les objets à courte durée de vie restent toujours dans la génération 0. Par exemple, les objets référencés pendant la durée de vie d’une requête web sont à courte durée de vie. Les singletons au niveau de l’application migrent généralement vers la génération 2.

Lorsqu’une application ASP.NET Core démarre, le GC :

  • Réserve de la mémoire pour les segments de tas initiaux.
  • Valide une petite partie de la mémoire lors du chargement du runtime.

Les allocations de mémoire précédentes sont effectuées pour des raisons de performances. L’avantage en termes de performances provient des segments de tas dans la mémoire contiguë.

GC. Collecter des mises en garde

En général, les applications ASP.NET Core en production ne doivent pas utiliser GC. Collectez explicitement. L’inducteur de nettoyage de la mémoire à des moments sous-optimaux peut réduire considérablement le niveau de performance.

GC. Collect est utile lors de l’examen des fuites de mémoire. L’appel de GC.Collect() déclenche un cycle de nettoyage de la mémoire bloquant qui tente de récupérer tous les objets inaccessibles à partir du code managé. Il est utile de comprendre la taille des objets dynamiques accessibles dans le tas et de suivre la croissance de la taille de la mémoire au fil du temps.

Analyse de l'utilisation de la mémoire d’une application

Les outils dédiés peuvent aider à analyser l’utilisation de la mémoire :

  • En comptant les références d’objets
  • En mesurant l’impact du GC sur l’utilisation du processeur
  • En mesurant l’espace mémoire utilisé pour chaque génération

Utilisez les outils suivants pour analyser l’utilisation de la mémoire :

Détection des problèmes de mémoire

Le gestionnaire des tâches peut être utilisé pour avoir une idée de la quantité de mémoire utilisée par une application ASP.NET. La valeur mémoire du gestionnaire des tâches :

  • Représente la quantité de mémoire utilisée par le processus ASP.NET.
  • Inclut les objets vivants de l’application et d’autres consommateurs de mémoire, tels que l’utilisation de la mémoire native.

Si la valeur mémoire du gestionnaire des tâches augmente indéfiniment et ne s’aplatit jamais, l’application a une fuite de mémoire. Les sections suivantes illustrent et expliquent plusieurs modèles d’utilisation de la mémoire.

Exemple d’application d’utilisation de la mémoire d’affichage

L’exemple d’application MemoryLeak est disponible sur GitHub. L’application MemoryLeak :

  • Inclut un contrôleur de diagnostic qui collecte la mémoire en temps réel et les données de GC pour l’application.
  • Présente une page d’index qui affiche la mémoire et les données de GC. La page d’index est actualisée toutes les secondes.
  • Contient un contrôleur d’API qui fournit différents modèles de charge de mémoire.
  • N’est pas un outil pris en charge, mais il peut être utilisé pour afficher les modèles d’utilisation de la mémoire des applications ASP.NET Core.

Exécutez MemoryLeak. La mémoire allouée augmente lentement jusqu’à ce qu’un GC ait lieu. La mémoire augmente, car l’outil alloue un objet personnalisé pour capturer des données. L’image suivante montre la page Index MemoryLeak lorsqu’un GC Gen 0 se produit. Le graphique montre 0 RPS (requêtes par seconde), car aucun point de terminaison d’API du contrôleur d’API n’a été appelé.

Chart showing 0 Requests Per Second (RPS)

Le graphique affiche deux valeurs pour l’utilisation de la mémoire :

  • Alloué : quantité de mémoire occupée par les objets managés
  • Jeu de travail : ensemble de pages dans l’espace d’adressage virtuel du processus qui résident actuellement dans la mémoire physique. Le jeu de travail affiché est la même valeur que celle affichée par le gestionnaire des tâches.

Objets temporaires

L’API suivante crée une instance string de 10 Ko et la retourne au client. À chaque requête, un nouvel objet est alloué en mémoire et écrit dans la réponse. Les chaînes étant stockées en tant que caractères UTF-16 dans .NET, chaque caractère prend 2 octets en mémoire.

[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
    return new String('x', 10 * 1024);
}

Le graphique suivant est généré comprenant une charge relativement faible pour montrer comment les allocations de mémoire sont affectées par le GC.

Graph showing memory allocations for a relatively small load

Le graphique précédent montre :

  • 4 000 RPS (requêtes par seconde).
  • Les récupérations de la mémoire de génération 0 se produisent environ toutes les deux secondes.
  • Le jeu de travail est constant à environ 500 Mo.
  • Le processeur est de 12 %.
  • La consommation et la libération de la mémoire (via GC) sont stables.

Le graphique suivant est extrait au débit maximal qui peut être géré par l’ordinateur.

Chart showing max throughput

Le graphique précédent montre :

  • 22 000 RPS
  • Les récupérations de la mémoire de génération 0 se produisent plusieurs fois par seconde.
  • Les récupérations de génération 1 sont déclenchées, car l’application a alloué beaucoup plus de mémoire par seconde.
  • Le jeu de travail est constant à environ 500 Mo.
  • Le processeur est de 33 %.
  • La consommation et la libération de la mémoire (via GC) sont stables.
  • Le processeur (33 %) n’étant pas surexploité, la récupération de mémoire peut supporter un nombre élevé d’allocations.

Récupération de mémoire de la station de travail ou du serveur

Le récupérateur de mémoire .NET propose deux modes différents :

  • La récupération de mémoire de la station de travail : optimisé pour le bureau.
  • La récupération de mémoire du serveur. La récupération de mémoire par défaut pour les applications ASP.NET Core. Optimisé pour le serveur.

Le mode de GC peut être défini explicitement dans le fichier projet ou dans le fichier runtimeconfig.json de l’application publiée. Le balisage suivant montre le paramètre ServerGarbageCollection dans le fichier projet :

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

La modification de ServerGarbageCollection dans fichier projet nécessite la reconstruction de l’application.

Remarque : la récupération de mémoire de serveur n’est pas disponible sur les machines à un seul cœur. Pour plus d’informations, consultez IsServerGC.

L’image suivante montre le profil de mémoire sous un RPS de 5 Ko à l’aide du GC de la station de travail.

Chart showing memory profile for a Workstation GC

Les différences entre ce graphique et la version du serveur sont significatives :

  • Le jeu de travail passe de 500 Mo à 70 Mo.
  • Le GC effectue des récupérations de génération 0 plusieurs fois par seconde au lieu de toutes les deux secondes.
  • Le GC passe de 300 Mo à 10 Mo.

Dans un environnement de serveur web classique, l’utilisation du processeur est plus importante que la mémoire. Par conséquent, le GC du serveur est meilleur. Si l’utilisation de la mémoire est élevée et que l’utilisation du processeur est relativement faible, le GC de la station de travail peut être plus performant. Par exemple, une haute densité hébergeant plusieurs applications web où la mémoire est rare.

GC utilisant Docker et de petits conteneurs

Lorsque plusieurs applications conteneurisées s’exécutent sur un seul ordinateur, le GC de la station de travail peut être plus performant que le GC du serveur. Pour plus d’informations, consultez Exécution avec le GC du serveur dans un petit conteneur et Exécution avec le GC du serveur dans un scénario de petit conteneur, Partie 1 : Limite matérielle pour le tas GC.

Références d’objets persistants

Le GC ne peut pas libérer les objets référencés. Les objets référencés mais qui ne sont plus nécessaires entraînent une fuite de mémoire. Si l’application alloue fréquemment des objets et ne parvient pas à les libérer une fois qu’ils ne sont plus nécessaires, l’utilisation de la mémoire augmente au fil du temps.

L’API suivante crée une instance string de 10 Ko et la retourne au client. La différence avec l’exemple précédent est que cette instance est référencée par un membre statique, ce qui signifie qu’elle n’est jamais disponible pour la récupération.

private static ConcurrentBag<string> _staticStrings = new ConcurrentBag<string>();

[HttpGet("staticstring")]
public ActionResult<string> GetStaticString()
{
    var bigString = new String('x', 10 * 1024);
    _staticStrings.Add(bigString);
    return bigString;
}

Le code précédent :

  • Exemple de fuite de mémoire classique.
  • Avec les appels fréquents, la mémoire de l’application augmente jusqu’à ce que le processus se bloque avec une exception OutOfMemory.

Chart showing a memory leak

Dans l’image précédente :

  • Le test de charge du point de terminaison /api/staticstring entraîne une augmentation linéaire de la mémoire.
  • Le GC tente de libérer de la mémoire à mesure que la pression de la mémoire augmente, en appelant une récupération de génération 2.
  • Le GC ne peut pas libérer la mémoire qui a fuité. Les allocations et le jeu de travail augmentent avec le temps.

Certains scénarios, tels que la mise en cache, exigent que les références d’objet soient conservées jusqu’à ce que la pression de la mémoire les force à être libérées. La classe WeakReference peut être utilisée pour ce type de code de mise en cache. Un objet WeakReference est récupéré sous des pressions de mémoire. L’implémentation par défaut de IMemoryCache utilise WeakReference.

Mémoire native

Certains objets .NET Core s’appuient sur la mémoire native. La mémoire native ne peut pas être récupérée par le GC. L’objet .NET utilisant de la mémoire native doit le libérer à l’aide du code natif.

.NET fournit l’interface IDisposable permettant aux développeurs de libérer de la mémoire native. Même si Dispose n’est pas appelé, les classes correctement implémentées appellent Dispose lorsque le finaliseur s’exécute.

Prenez le code suivant :

[HttpGet("fileprovider")]
public void GetFileProvider()
{
    var fp = new PhysicalFileProvider(TempPath);
    fp.Watch("*.*");
}

PhysicalFileProvider étant une classe managée, toutes les instances seront récupérées à la fin de la requête.

L’image suivante montre le profil de mémoire lors de l’appel continu de l’API fileprovider.

Chart showing a native memory leak

Le graphique précédent montre un problème évident avec l’implémentation de cette classe, car elle ne cesse d’augmenter l’utilisation de la mémoire. Il s’agit d’un problème connu qui fait l’objet d’un suivi dans ce problème.

La même fuite peut se produire dans le code utilisateur pour l’une des raisons suivantes :

  • Ne pas avoir libéré la classe correctement.
  • Avoir oublié d’appeler la méthode Dispose des objets dépendants qui doivent être supprimés.

Tas d'objets volumineux

Les cycles fréquents sans allocation de mémoire peuvent fragmenter la mémoire, en particulier lors de l’allocation de blocs de mémoire volumineux. Les objets sont alloués dans des blocs de mémoire contigus. Pour atténuer la fragmentation, lorsque le GC libère de la mémoire, il tente de la défragmenter. C'est ce que l'on appelle le compactage. Le compactage implique le déplacement d’objets. Le déplacement d’objets volumineux entraîne une pénalité au niveau des performances. Pour cette raison, le GC crée une zone de mémoire spéciale pour les objets volumineux, appelée tas d’objets volumineux (LOH). Les objets dont la taille est supérieure à 85 000 octets (environ 83 Ko) sont :

  • Placés sur le LOH.
  • Non compactés.
  • Récupérés pendant les GC de génération 2.

Lorsque le LOH est plein, le GC déclenche une récupération de génération 2. Les récupérations de génération 2 :

  • Sont intrinsèquement lentes.
  • Entraînent également le coût du déclenchement d’une récupération sur toutes les autres générations.

Le code suivant compacte immédiatement le LOH :

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

Consultez LargeObjectHeapCompactionMode pour plus d’informations sur le compactage du LOH.

Dans les conteneurs utilisant .NET Core 3.0 et versions ultérieures, le LOH est automatiquement compacté.

L'API suivante illustre ce comportement :

[HttpGet("loh/{size=85000}")]
public int GetLOH1(int size)
{
   return new byte[size].Length;
}

Le graphique suivant montre le profil de mémoire de l’appel du point de terminaison /api/loh/84975, sous la charge maximale :

Chart showing memory profile of allocating bytes

Le graphique suivant montre le profil de mémoire de l’appel du point de terminaison /api/loh/84976, en n’allouant qu’un octet de plus :

Chart showing memory profile of allocating one more byte

Remarque : la structure byte[] comporte des octets de surcharge. C’est pourquoi 84 976 octets déclenchent la limite de 85 000.

Comparaison des deux graphiques précédents :

  • Le jeu de travail est similaire pour les deux scénarios, environ 450 Mo.
  • Les requêtes en dessous de LOH (84 975 octets) présentent principalement des récupérations de génération 0.
  • Les requêtes au-dessus de LOH génèrent des récupérations de génération 2 constantes. Les récupérations de génération 2 sont coûteuses. Il faut plus de processeurs et le débit diminue de près de 50 %.

Les objets volumineux temporaires sont particulièrement problématiques, car ils provoquent des GC de gen2.

Pour des performances optimales, l’utilisation d’objets volumineux doit être réduite. Si possible, fractionnez les objets volumineux. Par exemple, l’intergiciel Mise en cache des réponses dans ASP.NET Core fractionne les entrées du cache en blocs inférieurs à 85 000 octets.

Les liens suivants montrent l’approche ASP.NET Core pour conserver les objets sous la limite LOH :

Pour plus d'informations, consultez les pages suivantes :

HttpClient

Une utilisation de HttpClient incorrecte peut entraîner une fuite des ressources. Les ressources système, telles que les connexions aux bases de données, les sockets, les descripteurs de fichiers, etc.:

  • Sont plus rares que la mémoire.
  • Sont plus problématiques en cas de fuite que la mémoire.

Les développeurs .NET expérimentés savent appeler Dispose sur des objets qui implémentent IDisposable. Le fait de ne pas supprimer des objets qui implémentent IDisposable entraîne généralement une fuite de la mémoire ou des ressources système.

HttpClient implémente IDisposable, mais ne doit pas être supprimé à chaque appel. Au lieu de cela, HttpClient doit être réutilisé.

Le point de terminaison suivant crée et supprime une nouvelle instance HttpClient à chaque requête :

[HttpGet("httpclient1")]
public async Task<int> GetHttpClient1(string url)
{
    using (var httpClient = new HttpClient())
    {
        var result = await httpClient.GetAsync(url);
        return (int)result.StatusCode;
    }
}

Sous la charge, les messages d’erreur suivants sont consignés :

fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HLG70PBE1CR1", Request id "0HLG70PBE1CR1:00000031":
      An unhandled exception was thrown by the application.
System.Net.Http.HttpRequestException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted --->
    System.Net.Sockets.SocketException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted
   at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port,
    CancellationToken cancellationToken)

Même si les instances HttpClient sont supprimées, la connexion réseau réelle prend un certain temps pour être libérée par le système d’exploitation. En créant continuellement de nouvelles connexions, les ports sont épuisés. Chaque connexion client nécessite son propre port client.

Pour éviter l’épuisement des ports, il faut réutiliser les mêmes instances HttpClient :

private static readonly HttpClient _httpClient = new HttpClient();

[HttpGet("httpclient2")]
public async Task<int> GetHttpClient2(string url)
{
    var result = await _httpClient.GetAsync(url);
    return (int)result.StatusCode;
}

L’instance HttpClient est libérée lorsque l’application s’arrête. Cet exemple montre que toutes les ressources jetables ne doivent pas être supprimées après chaque utilisation.

Consultez ce qui suit pour découvrir une meilleure façon de gérer la durée de vie d’une instance HttpClient :

Mise en pool d’objets

L’exemple précédent a montré comment l’instance HttpClient peut être rendue statique et réutilisée par toutes les requêtes. La réutilisation empêche l’épuisement des ressources.

La mise en pool d’objets :

  • Utilise le modèle de réutilisation.
  • Est conçue pour les objets dont la création est coûteuse.

Un pool est une récupération d’objets pré-initialisés qui peuvent être réservés et libérés entre des threads. Les pools peuvent définir des règles d’allocation telles que des limites, des tailles prédéfinies ou un taux de croissance.

Le package NuGet Microsoft.Extensions.ObjectPool contient des classes qui aident à gérer ces pools.

Le point de terminaison d’API suivant instancie une mémoire tampon byte qui est remplie de nombres aléatoires pour chaque requête :

        [HttpGet("array/{size}")]
        public byte[] GetArray(int size)
        {
            var random = new Random();
            var array = new byte[size];
            random.NextBytes(array);

            return array;
        }

Le graphique suivant montre l’appel de l’API précédente avec une charge modérée :

Chart showing calls to API with moderate load

Dans le graphique précédent, les récupérations de génération 0 se produisent environ une fois par seconde.

Le code précédent peut être optimisé en regroupant la mémoire tampon byte à l’aide d’ArrayPool<T>. Une instance statique est réutilisée entre les requêtes.

La différence avec cette approche est qu’un objet mis en pool est retourné à partir de l’API. Cela signifie que :

  • L’objet est hors de votre contrôle dès que vous revenez de la méthode.
  • Vous ne pouvez pas libérer l’objet.

Pour configurer la suppression de l’objet :

RegisterForDispose prend en charge l’appel Dispose de l’objet cible afin qu’il ne soit libéré qu’une fois la requête HTTP terminée.

private static ArrayPool<byte> _arrayPool = ArrayPool<byte>.Create();

private class PooledArray : IDisposable
{
    public byte[] Array { get; private set; }

    public PooledArray(int size)
    {
        Array = _arrayPool.Rent(size);
    }

    public void Dispose()
    {
        _arrayPool.Return(Array);
    }
}

[HttpGet("pooledarray/{size}")]
public byte[] GetPooledArray(int size)
{
    var pooledArray = new PooledArray(size);

    var random = new Random();
    random.NextBytes(pooledArray.Array);

    HttpContext.Response.RegisterForDispose(pooledArray);

    return pooledArray.Array;
}

L’application de la même charge que la version non regroupée provoque le graphique suivant :

Chart showing fewer allocations

La différence principale, ce sont les octets alloués, qui provoquent beaucoup moins de collections de génération 0.

Ressources supplémentaires