Distinguer l'attente du GC d'une fuite mémoire en .NET — Procédure pratique pour observer, comparer et prouver la croissance mémoire
· Mis à jour le: · Go Komura · .NET, C#, GC, Fuite de mémoire, Diagnostics, dotnet-counters, dotnet-dump, Exploitation, Réutilisation des actifs existants
1. Ce qu’il faut comprendre en premier lieu
Lorsqu’on exploite une application .NET, il arrive que la consommation mémoire augmente progressivement.
Le Gestionnaire des tâches ou top montre que la mémoire du processus augmente.
La consommation mémoire du conteneur augmente également.
Dans la supervision, le graphique du Working Set ou du RSS monte régulièrement.
Devant ce constat, on est tenté de conclure immédiatement à une « fuite mémoire ». Mais en .NET, une hausse de la mémoire du processus et une fuite mémoire ne sont pas la même chose.
.NET dispose du ramasse-miettes (GC). La mémoire n’est pas rendue à l’OS dès l’instant où un objet devient inutile. Le GC agit en fonction de l’état des allocations, des seuils du tas, de la pression mémoire, des générations et de la charge de travail.
Il en résulte des situations comme celles-ci :
- Des objets devenus inutiles n’ont pas encore été collectés
- Le GC a eu lieu, mais le Working Set du processus ne redescend pas immédiatement
- La mémoire augmente une seule fois lors du premier accès, du JIT, de la mise en cache ou du pool de connexions, puis se stabilise
- Le tas managé est stable, mais la mémoire native, les threads, les sockets ou une bibliothèque de traitement d’image augmentent de leur côté
- Des objets réellement devenus inutiles continuent d’être référencés depuis quelque part
Cet article traite de la manière de repérer ce dernier cas — la fuite réelle. Ce qu’il faut observer, ce n’est pas simplement la quantité de mémoire utilisée, mais ces trois points :
- La mémoire qui survit au GC augmente-t-elle ?
- Quel type augmente ?
- Qui référence ces objets ?
Une investigation de fuite mémoire en .NET ne doit pas s’arrêter à « la mémoire augmente » ; il faut aller jusqu’à « les objets de ce type augmentent et restent référencés depuis cette racine ».
Le code présenté dans cet article est publié sur GitHub sous forme d’un ensemble d’exemples compilables et exécutables (une bibliothèque de schémas de fuite typiques, une démo observant la différence entre l’attente du GC et la survie d’objets, et des tests unitaires vérifiant la rétention et la collecte avec WeakReference).
dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)
2. Avant tout, s’accorder sur le sens de « fuite mémoire »
En .NET, une fuite mémoire ne se limite pas à la forme « mémoire allouée puis oubliée » que l’on connaît en C ou en C++.
En code managé, c’est le GC qui collecte les objets. Le fait que le GC puisse ou non collecter un objet dépend d’une seule chose : existe-t-il encore une référence permettant d’atteindre cet objet ?
Autrement dit, la fuite mémoire typique en .NET ressemble à ceci :
Un objet n’est plus nécessaire du point de vue métier, mais il reste référencé — depuis un champ static, un cache, un événement, un Timer, une collection, une durée de vie DI, un contexte asynchrone, etc. — de sorte que, du point de vue du GC, il paraît encore utilisé.
Le GC est intelligent, mais il ne peut pas savoir si quelque chose est encore utile du point de vue métier. S’il est référencé, il le considère vivant.
C’est pourquoi, en .NET, il est plus parlant de parler de « rétention non intentionnelle » plutôt que de « fuite ».
À l’inverse, les états suivants n’indiquent pas immédiatement une fuite mémoire.
| État | Pourquoi ce n’est pas forcément une fuite |
|---|---|
| Le Working Set / RSS augmente | C’est la mémoire allouée au processus par l’OS ; elle ne correspond pas nécessairement à la quantité d’objets vivants sur le tas managé |
| Le Total Allocated augmente | Il s’agit du volume cumulé alloué depuis le démarrage, qui augmente naturellement tant que l’application fonctionne |
| Le GC Heap augmente momentanément | Il peut simplement s’agir d’objets non encore collectés en attendant le prochain GC |
| La mémoire augmente juste après le démarrage | Cela se produit couramment avec le JIT, le chargement des types, les caches initiaux, les pools de connexions, le déploiement des modèles |
| Le LOH est volumineux | Cela peut refléter la réutilisation de gros tableaux et buffers, la fragmentation, ou une stratégie de pooling |
| La mémoire ne redescend pas | Même après collecte par le GC, le processus ne rend pas forcément la mémoire à l’OS immédiatement |
À l’inverse, plus les observations suivantes se cumulent, plus le soupçon de fuite mémoire se renforce.
| Observation | Signification |
|---|---|
| Le tas après GC augmente à chaque répétition de la même opération | Le nombre d’objets survivants augmente |
| La taille de Gen 2 ou du LOH continue de croître | Des objets à longue durée de vie, ou de grands objets, restent en place |
| Le Count / la Size d’un même type augmente sur plusieurs dumps successifs | On peut identifier le type qui augmente |
gcroot montre des références depuis des champs static, des événements, des caches ou des services à longue durée de vie |
On peut expliquer pourquoi le GC ne peut pas collecter |
| La mémoire ne redescend pas après l’arrêt de la charge, même après un délai suffisant ou un GC de vérification | Il ne s’agit probablement pas d’une simple allocation temporaire |
3. Distinguer « de quelle mémoire » on parle
La première source de confusion dans une investigation mémoire est le mélange de différents indicateurs. Ils portent tous le nom de « mémoire », mais n’ont pas le même sens.
| Indicateur | Ce qu’il mesure | Comment le lire |
|---|---|---|
| Working Set / RSS | Les pages du processus présentes en mémoire physique | Point de vue de l’OS ; ce n’est pas le tas GC lui-même |
| Private Bytes / Commit | Mémoire committée détenue en propre par le processus | Inclut aussi la mémoire native, les piles, le code JIT, les segments GC |
| GC Heap Size | Quantité d’objets sur le tas managé | Le point d’entrée pour observer la mémoire gérée par le GC en .NET |
| Total Allocated | Volume cumulé alloué depuis le démarrage | Augmente en permanence ; à ne jamais utiliser seul pour juger une fuite |
| Gen 0 / Gen 1 / Gen 2 | Tas par génération | Ce qui reste en Gen 2 a une longue durée de vie |
| LOH | Tas accueillant les grands objets de 85 000 octets ou plus | Tend à augmenter avec les grands tableaux, chaînes et buffers |
| POH | Tas destiné aux objets épinglés (pinned) | Indice pour les effets de l’interopérabilité native et de l’épinglage |
| Finalization Queue | Objets en attente de finalisation | Indice de Dispose manqué ou de finaliseur engorgé |
Il n’est pas nécessaire de tout examiner en détail dès le départ. Décomposez d’abord la question ainsi :
La mémoire du processus augmente
↓
Le tas managé augmente-t-il aussi ?
↓
La quantité qui survit au GC augmente-t-elle ?
↓
Quel type augmente ?
↓
Qui le référence ?
En respectant cet ordre, on évite plus facilement de confondre « une hausse apparente de la mémoire » et « une véritable fuite ».
4. Le déroulé de la décision
En pratique, il est plus facile d’avancer avec le déroulé suivant :
1. Définir les conditions de reproduction
- Quelle API, quel écran, quel job, quel batch fait augmenter la mémoire
- Combien d'exécutions faut-il pour observer la hausse
- Que se passe-t-il quand on arrête la charge
2. Observer les tendances avec dotnet-counters
- Working Set
- GC Heap
- Gen 2 / LOH
- Total Allocated
- Nombre de GC
3. Comparer dans le temps
- Juste après le démarrage
- Après le warm-up
- Sous charge
- Après l'arrêt de la charge
- Après N répétitions de la même opération
4. Prendre au moins deux dumps
- before
- after
- si possible, aussi après l'arrêt de la charge
5. Rechercher les types qui augmentent
- dumpheap -stat
- gcdump report
- Visual Studio / PerfView
6. Vérifier les références
- gcroot
- gchandles
- finalizequeue
7. Établir un diagnostic
- Attente du GC
- Croissance normale d'un cache
- Fuite mémoire managée
- Problème de mémoire native
- Fragmentation du LOH ou allocations temporaires volumineuses
L’essentiel est de ne pas juger sur une seule valeur. Une fuite mémoire est « une tendance à la croissance continue » : on compare donc dans le temps, dans les mêmes conditions, plutôt que sur un point unique.
5. Les outils utilisés
Cet article utilise principalement les outils suivants :
| Outil | Quand l’utiliser |
|---|---|
dotnet-counters |
Observer la tendance du GC et du Working Set d’un processus en cours d’exécution |
dotnet-gcdump |
Prendre des statistiques légères des objets managés vivants |
dotnet-dump |
Inspecter le tas en détail et remonter jusqu’aux références avec dumpheap et gcroot |
| Visual Studio Memory Usage | Pour une comparaison graphique sous Windows |
| PerfView | Pour une analyse approfondie du GC / du tas / des traces sous Windows |
dotnet-trace |
Pour suivre les allocations et les événements GC dans le temps |
Commencez par installer les outils CLI.
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace
S’ils sont déjà installés, mettez-les à jour.
dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace
Recherchez le processus à étudier.
dotnet-counters ps
Dans les exemples qui suivent, l’ID du processus cible est noté <PID>.
Sous Linux, macOS et dans les environnements en conteneur, les outils de diagnostic doivent s’exécuter sous le même utilisateur que le processus cible. Selon l’environnement, vous pouvez aussi être affecté par TMPDIR, le port de diagnostic et l’espace de noms PID du conteneur.
Lorsque vous intervenez sur un environnement de production, ne prenez pas de dump directement : vérifiez d’abord la charge et son impact dans un environnement de test.
6. Commencer par observer les tendances avec dotnet-counters
Ce qu’il faut regarder en premier, ce n’est pas un dump détaillé, mais la tendance.
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime
La sortie varie légèrement selon la version de .NET.
À partir de .NET 9, les compteurs peuvent apparaître sous les noms de Meter System.Runtime ; en .NET 8 et versions antérieures, sous les noms traditionnels d’EventCounter.
Les éléments principaux à surveiller :
| Élément à surveiller | Ce qu’il indique |
|---|---|
dotnet.process.memory.working_set |
La mémoire résidente du processus du point de vue de l’OS |
dotnet.gc.last_collection.heap.size |
La taille du tas par génération après le dernier GC |
dotnet.gc.last_collection.memory.committed_size |
La quantité de mémoire committée par le GC |
dotnet.gc.heap.total_allocated |
Le volume cumulé alloué depuis le démarrage |
dotnet.gc.collections |
Le nombre de GC par génération |
dotnet.gc.pause.time |
Le temps de pause GC cumulé |
Vous pouvez aussi restreindre la surveillance à certains compteurs :
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]
Pour pouvoir les consulter plus tard, enregistrez-les en CSV :
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output counters.csv \
--counters System.Runtime
À ce stade, ce qu’il faut distinguer, ce sont les cas suivants.
6.1 Seul le Total Allocated augmente
dotnet.gc.heap.total_allocated est une valeur cumulative. Dès que l’application traite des requêtes, elle alloue des objets ; même si ces objets deviennent immédiatement inutiles et sont collectés par le GC, le volume cumulé d’allocation continue d’augmenter.
Le simple fait que Total Allocated augmente ne signifie donc pas une fuite mémoire. Ce qu’il faut observer, c’est si les allocations restent en place après coup.
Total Allocated : augmente
GC Heap Size : fluctue mais reste stable dans l'ensemble
Gen 2 / LOH : ne continue pas d'augmenter
Dans ce cas, il ne s’agit pas d’une fuite, mais d’une application qui alloue beaucoup.
La remédiation ne consiste pas à corriger une fuite, mais à réduire les allocations : réutilisation de buffers, révision d’un usage intensif de LINQ, réduction de la création de chaînes, révision de la sérialisation, etc.
6.2 Le Working Set augmente mais le GC Heap reste stable
Il arrive que le Working Set ou le RSS augmente alors que le GC Heap reste stable. Dans ce cas, il ne s’agit pas nécessairement d’une fuite d’objets managés.
Les facteurs possibles sont, entre autres :
- Le code JIT-compilé
- Les assemblies chargées
- Les piles de threads
- La mémoire des bibliothèques natives
- La mémoire non managée, comme celle allouée via
Marshal.AllocHGlobal - Les buffers côté natif pour l’image, la compression, la cryptographie, les pilotes de base de données
- Les buffers internes pour les sockets, les descripteurs de fichiers, SSL, HTTP/2, gRPC
- Le fait que l’OS ne récupère pas encore les pages physiques du processus
Dans cet état, examiner dumpheap en boucle ne permet parfois pas de trouver le coupable.
Le repère à retenir :
Working Set / RSS : augmente
GC Heap Size : stable
Gen 2 / LOH : stable
Dans ce cas, suspectez la mémoire native, les handles, le nombre de threads, les sockets et les bibliothèques externes plutôt qu’une fuite du tas managé .NET.
Ne vous limitez pas à dotnet-counters : examinez aussi les outils de l’OS, les métriques du conteneur, le nombre de handles, le nombre de threads, le tas natif et les métriques des bibliothèques externes.
6.3 Le GC Heap augmente sous charge mais redescend après l’arrêt
Il est normal que le GC Heap augmente sous charge.
Beaucoup de requêtes. Beaucoup d’objets temporaires. Traitement de gros JSON. Création temporaire de listes ou de tableaux.
Dans ces cas, le tas augmente jusqu’au prochain GC. Lorsque la charge s’arrête, un GC s’exécute et le tas peut redescendre.
Sous charge : le GC Heap augmente
Après l'arrêt de charge : le GC Heap redescend, ou revient à une valeur stable
Après répétition : la baseline ne continue pas d'augmenter
Dans ce cas, on peut conclure que « ce n’est simplement pas encore collecté » ou qu’« il y a beaucoup d’allocations temporaires ».
Cela dit, si les allocations temporaires sous charge sont trop nombreuses, le nombre de GC et le temps de pause augmentent, ce qui devient un problème de performance. Même sans être une fuite, cela reste une cible d’amélioration des performances.
6.4 Gen 2 / LOH continuent de croître après le GC
Voici le schéma auquel il faut prêter attention :
Répéter la même opération
↓
Gen 2 augmente
↓
Le LOH augmente
↓
Ne redescend pas même après l'arrêt de la charge
↓
La mesure suivante montre encore une augmentation
Gen 2 est la génération où résident les objets à longue durée de vie. Le LOH est le tas où tendent à se trouver les grands tableaux et chaînes de caractères.
Si cette croissance se poursuit, suspectez une fuite, un cache sans limite, la rétention de gros buffers, un défaut de désabonnement d’événement, une collection static, ou une rétention par un service à longue durée de vie.
À ce stade, passez à l’étape suivante.
7. Comment vérifier s’il s’agit d’une simple « attente du GC »
Pour vérifier si quelque chose « n’a simplement pas encore été collecté », observez l’état après avoir laissé au GC une occasion suffisante de s’exécuter.
Cependant, n’introduisez jamais GC.Collect() à la légère dans du code de production.
GC.Collect() force un GC. Un GC bloquant sur toutes les générations, en particulier, crée un temps de pause pour l’application. En exploitation normale, la règle est de laisser faire le GC.
Malgré tout, pendant une investigation, il peut être utile de vérifier, dans un environnement de vérification maîtrisé, si un objet survit à un GC forcé.
Dans une application console de vérification ou un environnement de reproduction, du code comme celui-ci permet d’examiner l’état après un GC complet :
static void ForceFullGcForDiagnosticsOnly()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
Le point important est de ne jamais utiliser ceci comme solution. C’est uniquement à des fins d’investigation.
Ce que l’on cherche à vérifier, c’est ce déroulé :
Avant l'opération
↓
Répéter l'opération N fois
↓
Arrêter la charge
↓
Attendre suffisamment, ou provoquer un GC complet dans l'environnement de vérification
↓
Le tas après GC revient-il à une valeur proche de celle d'avant l'opération ?
S’il y revient, il s’agit probablement d’une attente du GC ou d’allocations temporaires. S’il n’y revient pas, et que la baseline monte à chaque répétition de la même opération, quelque chose survit. On recherche alors ce « quelque chose » dans un dump.
8. Comparaison légère avec dotnet-gcdump
Pour une première comparaison, dotnet-gcdump est pratique.
dotnet-gcdump permet de capturer un GC dump depuis un processus .NET en cours d’exécution et d’en observer les statistiques par type sur le tas.
dotnet-gcdump collect --process-id <PID> --output before.gcdump
Après avoir appliqué la charge, capturez-le à nouveau.
dotnet-gcdump collect --process-id <PID> --output after.gcdump
Vous pouvez aussi produire un rapport simple depuis la CLI.
dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump > after-heap.txt
Ce qu’il faut regarder, c’est le Count et la Size de chaque type.
Par exemple, si des types comme les suivants ont fortement augmenté dans after, ils deviennent des cibles d’investigation :
Size (Bytes) Count Type
============ ===== ====
180,000,000 2,000,000 System.String
120,000,000 1,000,000 MyApp.Models.Customer
90,000,000 25,000 System.Byte[]
Ce qui compte, ce n’est pas « le type volumineux » mais « le type qui a augmenté ».
System.String et System.Byte[] figurent parmi les premiers dans la plupart des applications.
Être en tête ne fait pas d’eux le coupable pour autant.
L’angle de comparaison :
| before → after | Lecture |
|---|---|
| Le Count reste quasiment inchangé | Ce type n’est probablement pas le principal coupable |
| Le Count et la Size augmentent tous deux | Un candidat |
Les types MyApp.* augmentent |
Suspecter une rétention dans la logique métier |
System.Byte[] augmente |
Suspecter des buffers, la sérialisation, l’image, la compression, HTTP, la base de données |
System.String augmente |
Suspecter des caches, des logs, du JSON, des clés de dictionnaire, des chaînes dupliquées |
Task, Timer, CancellationTokenSource augmentent |
Suspecter du traitement asynchrone, des Timer, des désabonnements/annulations manquées |
dotnet-gcdump est un point d’entrée pratique pour la comparaison, mais sa capture provoque un GC de Gen 2. Dans les environnements à tas volumineux ou aux exigences de latence strictes, faites attention au temps de pause et à la consommation mémoire supplémentaire.
Sous Windows, vous pouvez ouvrir les fichiers .gcdump dans Visual Studio ou PerfView pour les comparer.
Sur des environnements non Windows, l’approche pratique consiste à examiner les statistiques par type avec le report en CLI, puis à passer à dotnet-dump pour approfondir les références.
9. Inspecter le tas et les références avec dotnet-dump
Une fois les « types qui augmentent » identifiés, l’étape suivante consiste à comprendre « pourquoi ils ne sont pas collectés ».
Pour cela, capturez un dump avec dotnet-dump et analysez-le avec les commandes SOS.
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-1.dmp
Attendez un moment, puis capturez-en un second.
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-2.dmp
La capture de dump est une opération lourde. Les dumps Full / Heap en particulier sont volumineux et sollicitent le processus et le conteneur. En production, faites attention au créneau horaire, à l’espace disque, aux limites mémoire du conteneur, ainsi qu’au risque que des informations personnelles ou confidentielles s’y retrouvent.
Analysez le dump obtenu.
dotnet-dump analyze myapp-2.dmp
Commencez par regarder les statistiques globales du tas.
> dumpheap -stat
La sortie donne le nombre d’instances et la taille par type.
MT Count TotalSize Class Name
00007f... 120000 3840000 MyApp.Models.Order
00007f... 250000 8000000 System.String
00007f... 10000 40000000 System.Byte[]
Filtrez sur un type précis.
> dumpheap -stat -type MyApp.Models.Order
Ou filtrez sur une MethodTable précise.
> dumpheap -mt <MT>
Une fois l’adresse d’une instance connue, remontez à ses références.
> gcroot <OBJECT_ADDRESS>
C’est le point le plus important. Avec gcroot, vous vérifiez pourquoi cet objet est encore vivant.
Imaginons que le chemin de référence suivant apparaisse :
static MyApp.CustomerCache._items
-> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
-> MyApp.Models.Customer
-> System.String
Dans ce cas, la raison pour laquelle le GC ne collecte pas est claire : Customer est référencé depuis un cache static, donc du point de vue du GC il est encore en cours d’utilisation.
C’est seulement à ce stade que l’on peut se poser les questions suivantes :
- Ce cache est-il vraiment nécessaire ?
- A-t-il une limite ?
- A-t-il une expiration ?
- Le design fait-il que les clés augmentent indéfiniment ?
- Est-il indexé sur le tenant, l’utilisateur, la date, l’ID de requête, etc., et croît-il sans limite ?
Dans une investigation de fuite mémoire, l’important est de ne pas s’arrêter à dumpheap -stat.
dumpheap -stat indique « ce qu’il y a en grande quantité » ; gcroot indique « pourquoi cela reste en place ». C’est ce dernier qui mène à la correction.
10. Aide-mémoire pour le diagnostic
Récapitulatif des schémas les plus fréquents en pratique :
| Observation | Possibilité | À examiner ensuite |
|---|---|---|
| Seul le Total Allocated augmente | Allocation normale, ou allocation excessive | Allocation Rate, nombre de GC, CPU, dotnet-trace |
| Le Working Set augmente mais le GC Heap est stable | Mémoire native, JIT, piles, rétention côté OS | Nombre de threads, nombre de handles, outils natifs, bibliothèques externes |
| Le GC Heap augmente uniquement sous charge et redescend après | Attente du GC, allocations temporaires | Gen 2 / LOH après l’arrêt de la charge, nombre de GC |
| Gen 2 continue de croître après le GC | Rétention d’objets à longue durée de vie | dumpheap -stat, gcroot |
| Le LOH continue de croître | Grands tableaux, buffers, fragmentation, chaînes gigantesques | System.Byte[], System.Char[], LOH, zones Free |
System.String est volumineux |
Cache de chaînes, JSON, logs, clés de dictionnaire | Rechercher les types propres qui détiennent les chaînes |
System.Byte[] est volumineux |
Buffers, sérialisation, image, compression, communication | Type propriétaire, retours ArrayPool manqués, interopérabilité native |
Task augmente |
Traitement asynchrone qui ne se termine jamais, files d’attente | Attentes async, annulation, canaux, files |
Timer augmente |
Timer non libéré | Dispose, désenregistrement, service à longue durée de vie |
CancellationTokenSource augmente |
CTS non libéré, trop de tokens liés | Dispose, déliaison, endroits créant des timeouts |
EventHandler ou delegate restent en place |
Désabonnement d’événement manqué | Écart de durée de vie entre publisher et subscriber |
| Finalization Queue augmente | Dispose manqué, finaliseur engorgé |
finalizequeue, thread finaliseur |
| Beaucoup de handles épinglés | Buffers épinglés, interopérabilité native | gchandles, POH, points d’épinglage |
11. Formes courantes de fuite mémoire
11.1 Collections static
La forme la plus facile à comprendre.
public static class CustomerStore
{
private static readonly List<Customer> Customers = new();
public static void Add(Customer customer)
{
Customers.Add(customer);
}
}
Dans ce code, tout Customer ajouté à Customers reste en place aussi longtemps que le processus vit. Même si l’intention était un stockage temporaire, tant qu’un objet est référencé depuis un champ static, le GC ne le collecte pas.
L’orientation de la correction dépend de l’usage :
- Imposer une limite
- Ajouter une expiration
- Utiliser un mécanisme de cache tel que
MemoryCache - Supprimer explicitement les éléments
- Abandonner le static et migrer vers un service avec une durée de vie appropriée
- Si l’objectif est la persistance, migrer vers une base de données ou un stockage externe
L’important n’est pas de dire que « static, c’est mal », mais de comprendre et de respecter le fait que tout ce que l’on place dans un static devient à longue durée de vie.
11.2 Cache sans limite
Un cache utilise intentionnellement de la mémoire ; une croissance conforme à la conception n’est donc pas une fuite. Mais un cache sans limite ni expiration devient de facto une fuite mémoire.
public sealed class ReportCache
{
private readonly Dictionary<string, Report> _cache = new();
public Report GetOrCreate(string userId, DateTime date)
{
var key = $"{userId}:{date:O}";
if (_cache.TryGetValue(key, out var report))
{
return report;
}
report = BuildReport(userId, date);
_cache[key] = report;
return report;
}
}
Dans cet exemple, si le nombre de combinaisons userId / date continue de croître, le cache continue de croître aussi.
Les cas particulièrement dangereux sont ceux où la clé inclut des valeurs comme :
- Un ID de requête
- L’heure actuelle
- Un GUID
- Un ID de session
- Une chaîne construite à partir d’une saisie utilisateur non normalisée
- Une condition SQL ou de recherche transformée telle quelle en chaîne
Pour tout cache, définissez à l’avance les conditions suivantes :
| Condition | Exemple |
|---|---|
| Nombre maximal d’entrées | Jusqu’à 10 000 entrées |
| Taille maximale | Jusqu’à 256 Mo |
| Expiration glissante | 30 minutes après le dernier accès |
| Expiration absolue | 6 heures après la création |
| Conditions de purge | Suppression du tenant, suppression de l’utilisateur, changement de configuration |
| Éléments à surveiller | Nombre d’entrées, taille estimée, taux de succès, nombre d’évictions |
Ce n’est pas « c’est un cache, donc il peut grossir », mais « jusqu’où peut-il grossir » qu’il faut décider.
11.3 Désabonnement d’événement manqué
Les événements deviennent une fuite lorsqu’un publisher à longue durée de vie continue de référencer un subscriber à courte durée de vie.
public sealed class OrderViewModel
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
Si OrderService est un singleton et qu’un OrderViewModel est créé pour chaque écran, l’événement d’OrderService continue de référencer l’OrderViewModel. Fermer l’écran ne libère pas le ViewModel tant qu’on ne s’est pas désabonné.
Exemple de correction :
public sealed class OrderViewModel : IDisposable
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
public void Dispose()
{
_service.OrderChanged -= OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
Dans gcroot, cela peut apparaître comme une référence via un delegate ou un event handler.
Ce schéma revient souvent avec WPF, WinForms, les services à longue durée de vie, les message brokers et les event aggregators.
11.4 Timer non libéré
System.Threading.Timer, PeriodicTimer, ou encore les subscriptions Reactive Extensions restent aussi en place s’ils ne sont pas libérés.
public sealed class PollingWorker
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
private void Poll()
{
// polling
}
}
Si ce PollingWorker est censé être un objet temporaire, il faut que le design libère le Timer.
public sealed class PollingWorker : IDisposable
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
public void Dispose()
{
_timer.Dispose();
}
private void Poll()
{
// polling
}
}
Un Timer détient le delegate de son callback, d’où une chaîne de références peut mener jusqu’à l’objet cible.
11.5 IDisposable non libéré
Un IDisposable non libéré ne se manifeste pas forcément comme une fuite sur le tas managé.
Cela peut apparaître comme un problème de ressources : fichiers, sockets, connexions à la base de données, handles natifs, buffers.
public async Task<string> ReadAsync(string path)
{
var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
Dans cet exemple, StreamReader ferme stream, donc ce n’est souvent pas un gros problème — mais dans du code où la propriété est ambiguë, des fuites se produisent.
La règle de base consiste à clarifier la propriété avec using / await using.
public async Task<string> ReadAsync(string path)
{
await using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
Un Dispose manqué se manifeste par des symptômes comme :
- Le nombre de handles augmente
- Les sockets s’accumulent
- Les fichiers ne se ferment jamais
- La mémoire native augmente
- La Finalization Queue augmente
- La mémoire du processus augmente alors que le GC Heap est stable
Dans ce cas, dumpheap seul ne suffit pas.
Examinez aussi les handles et sockets de l’OS, ainsi que l’état des bibliothèques externes.
11.6 AsyncLocal et rétention de contexte
AsyncLocal<T> est pratique, mais si ce que l’on y stocke est volumineux, cela peut rester en place longtemps.
Une petite valeur, comme un ID de corrélation de log, pose rarement problème. Mais y placer des informations utilisateur, un corps de requête, un gros DTO ou un contexte de base de données conduit à une rétention non intentionnelle.
public static class RequestContext
{
public static readonly AsyncLocal<RequestInfo?> Current = new();
}
Comme AsyncLocal suit le flux asynchrone, il peut être plus difficile à repérer qu’un simple champ static.
Envisagez de garder ce que vous y stockez petit et bien défini, et de le remettre à null lorsqu’il n’est plus nécessaire.
11.7 Confusion de durée de vie DI
Dans les conteneurs DI comme celui d’ASP.NET Core, singleton, scoped et transient ont des durées de vie différentes.
Si un singleton à longue durée de vie conserve des données propres à chaque requête, des objets peuvent rester en place après la fin de la requête.
public sealed class AuditBuffer
{
private readonly List<RequestAudit> _items = new();
public void Add(RequestAudit item)
{
_items.Add(item);
}
}
Si cette classe est un singleton, _items vit aussi longtemps que l’application.
Si la mise en tampon est intentionnelle, la conception a besoin d’une limite, d’un envoi, d’une suppression et d’une contre-pression (backpressure). S’il s’agit simplement de « on regardera peut-être plus tard », il vaut mieux écrire ces données dans les logs ou un stockage externe.
12. Le LOH est particulièrement facile à mal interpréter
LOH signifie Large Object Heap. En .NET, les grands objets sont placés sur un tas distinct des petits objets ordinaires. L’exemple typique est un grand tableau.
var buffer = new byte[1024 * 1024 * 10]; // 10MB
Les trois problèmes courants du LOH :
- Créer fréquemment de grands objets
- Conserver longtemps de grands objets
- La fragmentation due à la création et à la destruction de grands objets
Un LOH qui augmente ne signifie pas immédiatement une fuite. Avec une conception qui réutilise de grands buffers, il peut croître jusqu’à une certaine taille puis se stabiliser ; et même quand le GC collecte, le Working Set ne redescend pas forcément tout de suite.
Cependant, les états suivants doivent éveiller les soupçons :
System.Byte[]augmente à chaque opérationSystem.Char[]ou desStringgigantesques augmentent- La mémoire ne redescend pas après un traitement d’image, de PDF, d’Excel, de ZIP, de cryptographie ou de compression
- Des tableaux obtenus via
ArrayPool<T>.Rentne sont pas rendus - De grandes réponses sont chargées intégralement en mémoire
MemoryStream.ToArray()est utilisé abondamment
Si vous utilisez ArrayPool<T>, rendez systématiquement ce que vous avez emprunté.
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);
try
{
// use buffer
}
finally
{
pool.Return(buffer);
}
Cela dit, le fait de rendre au pool ne fait pas forcément redescendre immédiatement la mémoire du processus. Les pools peuvent conserver de la mémoire en vue de sa réutilisation.
Là encore, ce qu’il faut observer, c’est si cela continue de croître, s’il existe une limite, et si la mémoire est effectivement réutilisée.
13. Comment lire gcroot
gcroot affiche depuis où un objet donné est référencé.
Voici un tableau des racines typiques :
| Racine | Signification |
|---|---|
| static field | Référencé depuis un champ static d’un type |
| local variable / stack | Référencé depuis la pile d’un thread en cours d’exécution |
| GC handle | Référencé via un GCHandle, un épinglage (pin), un delegate, de l’interop |
| finalization queue | Retenu en attente de finalisation |
| thread / async state machine | Retenu par un traitement asynchrone en cours d’exécution ou d’attente |
Le point à surveiller dans l’investigation est l’écart de durée de vie :
Objet à longue durée de vie
-> Objet censé être à courte durée de vie
Quand cette forme apparaît, c’est un candidat à la fuite.
Par exemple, ceci est suspect :
SingletonService
-> List<RequestContext>
-> RequestContext
-> LargeDto
SingletonService vit aussi longtemps que l’application entière.
Si des RequestContext propres à chaque requête s’y accumulent, la conception doit être revue.
À l’inverse, une racine comme celle-ci peut être parfaitement normale selon le moment :
Thread stack
-> Controller action local variable
-> RequestDto
Pendant le traitement d’une requête, il est normal que des variables locales soient encore présentes.
C’est pourquoi le moment de la capture du dump est important.
Prendre des dumps non seulement sous charge, mais aussi après l’arrêt de la charge, une fois les files vidées, et après une période d’inactivité, facilite grandement le diagnostic.
14. « La mémoire a baissé après un GC forcé » ne veut pas dire « c’est réglé »
Pendant l’investigation, vous appelez GC.Collect() et la mémoire baisse. Il est dangereux d’en conclure « alors il suffit d’appeler GC.Collect() périodiquement ».
Un GC forcé ne supprime pas la cause racine. Il se contente de collecter sur place des objets qui n’avaient simplement pas encore été ramassés.
Si un taux d’allocation élevé est le problème, un GC forcé augmente le temps de pause et dégrade les performances. S’il s’agit d’une véritable fuite, les objets encore référencés ne sont pas non plus collectés par un GC forcé.
Ce qu’il faut observer dans l’investigation, c’est la distinction suivante :
| Après un GC forcé | Diagnostic |
|---|---|
| Baisse fortement, puis la baseline reste stable | Attente du GC ou allocations temporaires comme cause principale |
| Baisse un peu, mais le plancher remonte à chaque répétition | Une partie survit. Candidat à la fuite |
| Baisse à peine | Encore référencé, ou la cause principale se situe hors du tas GC |
| Le GC Heap baisse mais pas le Working Set | Rétention possible côté OS / segments GC / natif |
Avant d’exécuter GC.Collect() périodiquement en production, identifiez systématiquement ce qui augmente réellement.
15. Procédure d’investigation utilisée en pratique
À partir d’ici, formalisons la procédure suivie lors d’une investigation réelle.
15.1 Fixer le scénario de reproduction
Commencez par fixer les conditions de l’investigation.
Cible : /api/report/export
Opération : 100 exécutions dans les mêmes conditions
Intervalle mesure : 5 secondes
Fenêtre : 5 min de warm-up + 10 min de charge + 5 min d'inactivité
Environnement : staging / build Release / configuration équivalente à la production
Dans une investigation mémoire, on ne peut pas conclure si l’on fait une opération différente à chaque fois. Fixez « ce qui a été fait quand la mémoire a augmenté ».
15.2 Prendre une baseline
Utilisez l’état après le warm-up comme baseline, pas juste après le démarrage.
La raison est qu’il existe, juste après le démarrage, des hausses ponctuelles comme celles-ci :
- Le JIT
- La construction du conteneur DI
- Le chargement de la configuration
- La première connexion à la base de données
- La première connexion TLS / HTTP
- La génération des métadonnées du sérialiseur JSON
- L’initialisation de Razor / des templates
- L’initialisation du logger et des métriques
L’ordre à suivre :
1. Démarrer l'application
2. Appeler quelques fois les health checks ou une API représentative
3. Attendre environ 1 à 5 minutes
4. Capturer counters et un dump comme baseline
15.3 Collecter les counters sous charge
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output report-export-counters.csv \
--counters System.Runtime
Effectuez en parallèle l’opération de reproduction. Ce qui vous intéresse, c’est la forme du graphique.
Forme proche de la normale :
augmente sous charge
oscille avec les GC
redescend après l'arrêt de la charge
la baseline ne continue pas d'augmenter
Forme suspecte :
augmente proportionnellement au nombre d'opérations
le plancher de Gen 2 / LOH remonte
ne redescend pas même après l'arrêt de la charge
le plancher remonte encore avec la charge suivante
15.4 Prendre deux dumps
Capturez-les avant et après la charge.
dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# appliquer la charge
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp
Si possible, capturez-en un aussi après l’arrêt de la charge.
# après l'arrêt de la charge, une fois les files vides, et après une attente suffisante
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp
Pour la comparaison, idle-after compte autant que before et after.
Même si la mémoire a augmenté sous charge, un retour à la normale après l’inactivité peut indiquer qu’il ne s’agit pas d’une fuite.
15.5 Examiner les types qui ont augmenté
dotnet-dump analyze after.dmp
> dumpheap -stat
Examinez le côté before de la même façon.
Le faire manuellement est acceptable ; commencez par comparer les types en tête de liste.
Les points à examiner :
- Les types de vos propres namespaces augmentent-ils ?
- Y a-t-il un type propre caché derrière
System.String? - Qui détient les
System.Byte[]? List<T>ouDictionary<TKey,TValue>augmentent-ils ?- Des
Taskou des state machines async augmentent-ils ? - Des
TimerouCancellationTokenSourceaugmentent-ils ?
15.6 Examiner les références
Repérez des adresses d’objets candidats et lancez gcroot.
> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>
À partir du résultat de gcroot, recherchez le parent qui retient l’objet.
MyApp.Services.ReportCache
-> Dictionary<string, ReportResult>
-> ReportResult
À ce stade, les cibles de la revue de code deviennent visibles.
ReportCacheest-il un singleton ?- A-t-il une limite ?
- Les entrées sont-elles supprimées ?
- Les clés continuent-elles d’augmenter ?
ReportResultest-il trop volumineux ?- Faudrait-il déporter cela vers une base de données ou des fichiers plutôt qu’un cache ?
16. Quand utiliser dotnet-trace
dotnet-dump est adapté à l’observation d’un instantané, pour voir « ce qui reste en résultat ». En revanche, pour voir « quand et où se produisent les allocations massives », utilisez dotnet-trace.
Par exemple, une trace incluant les événements liés au GC :
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:01:00 \
--clrevents gc+gchandle \
--clreventlevel informational \
--output gc-trace.nettrace
Si vous voulez aller jusqu’à l’échantillonnage des allocations, le volume d’événements augmente ; commencez donc par de courtes durées dans un environnement de vérification.
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:00:30 \
--clrevents gc+gcsampledobjectallocationhigh \
--clreventlevel informational \
--output allocation-trace.nettrace
Les traces sont utiles sous un angle différent des dumps.
| Ce que vous voulez voir | L’outil adapté |
|---|---|
| Ce qui reste | dump / gcdump |
| Qui le référence | dump + gcroot |
| Quand ont eu lieu les allocations massives | trace |
| Quand les GC se sont produits | counters / trace |
| Si le temps de pause est le problème | counters / trace |
Dans une investigation de fuite, il est efficace de commencer par le dump pour voir « ce qui reste », puis, si nécessaire, la trace pour voir « où cela se crée ».
17. Exposer des métriques de vérification dans le code
Le diagnostic sérieux doit être mené avec des outils externes, mais disposer d’une journalisation diagnostique simple côté application est utile.
Par exemple, exposer des informations GC via un endpoint d’administration ou un log périodique :
public static class GcDiagnostics
{
public static object Snapshot()
{
var info = GC.GetGCMemoryInfo();
return new
{
TotalMemory = GC.GetTotalMemory(forceFullCollection: false),
HeapSizeBytes = info.HeapSizeBytes,
FragmentedBytes = info.FragmentedBytes,
MemoryLoadBytes = info.MemoryLoadBytes,
HighMemoryLoadThresholdBytes = info.HighMemoryLoadThresholdBytes,
Gen0Collections = GC.CollectionCount(0),
Gen1Collections = GC.CollectionCount(1),
Gen2Collections = GC.CollectionCount(2)
};
}
}
Cette seule information ne permet pas de déterminer une fuite. Mais en cas d’incident, elle facilite les diagnostics suivants :
- Gen 2 augmente-t-il brutalement ?
- HeapSize augmente-t-il ?
- FragmentedBytes augmente-t-il ?
- L’écart entre TotalMemory et la mémoire du processus est-il important ?
- La tendance a-t-elle changé après un déploiement ?
Si vous l’intégrez aux logs applicatifs, attention à ne pas en abuser. Un diagnostic lourd effectué à haute fréquence devient lui-même une charge.
18. Le critère permettant de conclure à une « fuite mémoire »
À la fin de l’investigation, vous devriez pouvoir l’expliquer sous cette forme :
Symptôme :
Après 100 exécutions de /api/report/export, le GC Heap reste 300 Mo plus élevé même après l'arrêt de la charge.
Observation :
Avec dotnet-counters, la taille du tas de Gen 2 a augmenté proportionnellement au nombre d'opérations.
Ce n'est pas seulement le Working Set, mais aussi le GC Heap qui augmentait.
Comparaison :
En comparant before.dmp et after.dmp, MyApp.Models.ReportResult avait augmenté de 12 000 instances.
Référence :
gcroot a montré une référence depuis MyApp.Services.ReportCache._items.
Cause :
ReportCache était un singleton indexé sur l'ID utilisateur + l'heure actuelle, sans suppression, expiration ni limite.
Correction :
Remplacé par MemoryCache, avec une limite de taille et une expiration configurées.
Le nombre d'entrées du cache a été transformé en métrique.
Si vous pouvez expliquer les choses jusque-là, le rapport ne se limite plus à « la mémoire augmente » : il relie les conditions de reproduction, les observations, le type qui augmente, les références, la cause et la correction.
19. Points de vigilance pendant l’investigation
19.1 Observer avec un build Release
Un build Debug peut donner une apparence différente de l’exploitation réelle, en raison des optimisations, de la durée de vie des variables locales et des informations de débogage.
Pour une investigation représentative de la production, vérifiez avec un build Release, une configuration proche de l’exploitation et un volume de données proche.
19.2 Ne pas juger uniquement juste après le démarrage
Juste après le démarrage, la mémoire augmente en raison de nombreuses initialisations.
Prenez une baseline après le warm-up et observez la croissance à partir de là.
19.3 Ne pas incriminer un type sur la base d’un seul dump
Le type en tête du tas n’est pas nécessairement le coupable.
System.String et System.Byte[] apparaissent volumineux dans la plupart des applications.
Ce qui compte, c’est de savoir s’ils ont augmenté dans le temps, et qui les détient.
19.4 Les dumps contiennent des informations sensibles
Un dump mémoire peut contenir des requêtes, des identifiants d’authentification, des chaînes de connexion, des données personnelles et des données métier.
Définissez des règles pour le lieu de stockage, la sortie du site, le partage et la suppression.
19.5 En conteneur, la capture de dump est elle-même un risque
Si les limites mémoire du conteneur sont strictes, la mémoire supplémentaire et les page-ins provoqués par la capture d’un dump peuvent entraîner un OOM Kill.
Avant de capturer un dump dans un conteneur de production, testez d’abord en staging et vérifiez les limites, l’espace disque, les permissions et l’espace de noms PID.
19.6 Il existe aussi des fuites en dehors du GC Heap
Ce n’est pas parce qu’il s’agit d’une investigation .NET que tout se manifeste sur le tas GC.
Dans des problèmes comme les suivants, la mémoire du processus peut augmenter alors que le GC Heap reste stable :
- Bibliothèques natives
- P/Invoke
- COM
- Traitement d’image
- Bibliothèques de compression
- Traitement cryptographique
- Pilotes de base de données
- Sockets
Marshal.AllocHGlobalNativeMemory.Alloc- Trop de threads
Dans ce cas, dumpheap de dotnet-dump seul ne suffit pas.
Il faut examiner les diagnostics côté OS, les métriques des bibliothèques externes, les handles, les threads et la mémoire native.
20. Vérification après la correction
Une fois l’emplacement suspecté de fuite corrigé, refaites la mesure avec la même procédure.
Avant la correction :
Après 100 exécutions, Gen 2 : +300 Mo
ReportResult : +12 000 instances
Après la correction :
Après 100 exécutions, Gen 2 reste stable en dessous de +20 Mo
ReportResult revient à la baseline après l'arrêt de la charge
Le nombre d'entrées du cache est stable à la limite de 1 000 entrées
Lors de la vérification d’une correction, comparez toujours dans les mêmes conditions :
- Même volume de données
- Même nombre d’exécutions
- Même durée de charge
- Même warm-up
- Même intervalle d’observation
- Mêmes outils
Une investigation mémoire n’est convaincante que si la comparaison avant/après correction est solide.
21. Conclusion
Quand la mémoire augmente en .NET, ne concluez pas immédiatement à une fuite : procédez au tri dans cet ordre.
- Ne pas juger sur le seul Working Set / RSS
- Observer le GC Heap, Gen 2, le LOH et le nombre de GC avec
dotnet-counters - Comparer sous charge, après l’arrêt de charge, et dans le temps
- Examiner les types qui ont augmenté avec
dotnet-gcdumpoudotnet-dump - Examiner les références avec
gcroot - Vérifier les static, les caches, les événements, les Timer, les durées de vie DI, les contextes asynchrones
- Si le GC Heap est stable, suspecter aussi la mémoire native et les problèmes côté OS
La différence entre « simplement pas encore collecté par le GC » et « fuite mémoire » se joue finalement sur les références.
Si un objet devenu inutile n’a plus de référence, il est collecté au moment du GC. S’il devrait être inutile mais reste référencé, le GC ne peut pas le collecter.
Autrement dit, l’objectif de l’investigation est le suivant :
Qu'est-ce qui augmente ?
Qu'est-ce qui survit à chaque GC ?
Qui le référence ?
Cette référence est-elle nécessaire du point de vue de la conception ?
Une fois cela établi, vous n’êtes plus ballotté par les graphiques de mémoire et pouvez traduire le problème en corrections concrètes dans le code.
Références
- Le code d’exemple complet de cet article (bibliothèque, démo, tests unitaires) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-gc-or-memory-leak
- .NET: Fundamentals of garbage collection
- .NET: Debug a memory leak
- .NET CLI: dotnet-counters diagnostic tool
- .NET CLI: dotnet-dump diagnostic tool
- .NET CLI: dotnet-gcdump diagnostic tool
- .NET CLI: dotnet-trace diagnostic tool
- .NET: Induced collections
- .NET: Large object heap
- .NET: Workstation and server garbage collection
Articles associés
Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.
Qu'est-ce qu'un PDB (Program Database) ? — Comprendre les informations de débogage, les symboles et Source Link
Qu'est-ce qu'un PDB (Program Database) ? Nous expliquons de façon pratique ce qu'il contient et ne contient pas, ainsi que ses liens avec...
Bien gérer les jetons d'emprunt d'identité Windows — emprunt des privilèges par thread et retour sécurisé au contexte d'origine
Un guide pratique des jetons d'emprunt d'identité sous Windows : jetons d'accès, jetons principaux, jetons de thread, niveaux d'emprunt d...
Le malentendu selon lequel TCP renvoie les données dans les mêmes unités que Send — concevoir la réception comme un flux d'octets
Supposer que TCP restitue les données dans les mêmes unités que Send ou Write mène à des fragmentations, des fusions, des données corromp...
Jusqu'à quand les applications VB6 continueront-elles de fonctionner ? — état du support du runtime et démarche concrète vers une migration .NET
Jusqu'à quand les applications VB6 continueront-elles de fonctionner ? Cet article clarifie l'asymétrie entre la politique de support du ...
Date, heure et fuseaux horaires dans les applications métier — des pièges de DateTime au principe de stockage en UTC et à la conception des tests
Un déplacement de serveur fait dériver les horaires de neuf heures : cet article remonte à la source des incidents de date/heure, la prop...
Sujets associés
Ces pages replacent le sujet dans un contexte plus large de services et de décisions.
Thèmes techniques Windows
Portail des sujets sur le développement Windows, l'analyse des incidents et la valorisation des actifs existants.
Services liés à ce sujet
Cet article est directement lié aux services suivants.
Développement d'applications Windows
Applications métier, intégration d'équipements et outils de communication, des besoins au développement.
Questions fréquentes
Questions souvent posées lors d’une consultation sur le sujet de cet article.
- Une hausse de la mémoire utilisée par une application .NET signifie-t-elle une fuite mémoire ?
- Une hausse de la mémoire du processus et une fuite mémoire ne sont pas la même chose. En .NET, le GC agit en fonction de l'état des allocations et des seuils du tas ; il se peut simplement que des objets devenus inutiles n'aient pas encore été collectés, ou que l'OS ne récupère pas immédiatement la mémoire après un GC. Les trois points à observer sont : la mémoire qui survit au GC augmente-t-elle, quel type augmente, et qui référence cet objet.
- Quels outils utiliser pour étudier une fuite mémoire en .NET ?
- Commencez par dotnet-counters pour observer la tendance du Working Set, du GC Heap, de Gen 2/LOH et du nombre de GC. Prenez ensuite des GC dump avant et après charge avec dotnet-gcdump, et comparez le Count et la Size par type pour identifier les types qui ont augmenté. Enfin, prenez un dump du tas avec dotnet-dump et remontez jusqu'à la référence responsable avec dumpheap -stat et gcroot, pour comprendre pourquoi l'objet n'est pas collecté. L'important n'est pas une valeur ponctuelle, mais une comparaison dans le temps dans les mêmes conditions.
- Quels sont les schémas de fuite mémoire les plus courants en .NET ?
- Les cas typiques sont : des ajouts continus à une collection static, un cache sans limite ni expiration, un défaut de désabonnement d'événement vis-à-vis d'un publisher à longue durée de vie, un Timer non libéré, un IDisposable non libéré, ou une confusion de durée de vie DI où un singleton conserve des données propres à une requête. En .NET, il est plus juste de parler de « rétention non intentionnelle » — un objet devenu inutile mais encore référencé — plutôt que d'un simple oubli de libération.
- Exécuter GC.Collect() périodiquement résout-il les problèmes de mémoire ?
- Non. Un GC forcé ne fait que collecter sur place les objets qui n'avaient pas encore été ramassés ; il ne supprime pas la cause racine. Si le taux d'allocation élevé est le problème, un GC forcé allonge les temps de pause et dégrade les performances ; s'il s'agit d'une vraie fuite, les objets encore référencés ne seront pas collectés même par un GC forcé. Il peut être utile, dans un environnement de vérification maîtrisé, de constater si un objet survit à un GC forcé à des fins d'investigation, mais avant d'introduire cela en production comme solution, il faut impérativement identifier ce qui augmente réellement.
Profil de l’auteur
Page de présentation de l’auteur de l’article.
Go Komura
Représentant de KomuraSoft LLC
Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.
Liens publics