Parmi les différentes missions que j'assure régulièrement, l'audit de code et plus particulièrement la détection de fuites mémoire occupe un temps non négligeable de cette activité.
Il existe de nombreux articles traitant du sujet et l'idée ici n'est pas d'en réécrire un n-ième. Je vais simplement tenter de formaliser un cas d'école et de vous illustrer l'approche à adopter (en .NET pour changer un peu des derniers billets ;-)).
Avant tout, il est très important de comprendre que l'analyse de fuite mémoire relève de l'expertise, je veux dire par là qu'il est très difficile, voire impossible de réaliser une analyse fine sans comprendre très précisément le mode de fonctionnement de la mémoire managée des applications à base de machine virtuelle.
Suite:
Un bon début consiste à lire l'article de Thomas sur DotNetGuru. Cet article (même s'il date de 2002) traite de toutes les caractéristiques importantes d'un ramasse-miettes : les handles, les weak-references, les générations, etc .. A noter que la CLR 4 verra d'importants changements dans le GC de .NET.
Comme l'indique l'article de Thomas, les GC générationnels proposent plusieurs files de collection, une manière de diviser la mémoire pour régner. La génération 0 contient les objets les plus récents (les objets alloués sur la pile ou dans un bloc local temporaire d'une boucle). Les collections (dans le sens "libération") y sont fréquentes et d'une durée courte. Ce qui n'est pas le cas de la génération 1 (d'une taille plus grande) ou 2 (contenant le reste de la mémoire). Plus la génération est grande, plus les objets sont anciens. Une fuite potentielle aura tendance à déporter vers cette dernière génération les objets non libérés.
Si la gestion de la mémoire est un sujet complexe il me semble hasardeux de développer aujourd'hui sans réellement comprendre le mode de fonctionnement d'un GC. L'idée que la mémoire est libérée automatiquement quelque soit l'application est une idée évidemment révolue. Le développeur, de part sa manière de coder, va indéniablement influer sur le comportement de la mémoire et indirectement sur les performances de l'application.
Contrairement à une idée reçue, il existe très peu d'applications n'ayant aucune fuite mémoire. Dans la pratique, on ne s'en aperçoit pas car elles sont minimes. Il faudrait laisser tourner ces applications plusieurs jours pour percevoir l'effet d'escalier.
La méthodologie et les cas de fuite
Lorsqu'une fuite mémoire apparaît dans une application .NET, il y a toute méthodologie d'analyse à adopter. La première étape consiste à cerner le type d'application. Est-ce une webapp ASP.NET ? est-ce un service Windows Managé (du hosting WCF ou WWF) ? est-ce une application Windows Form classique ? Est-ce du WPF ou du Silverlight ?
La réponse à cette question va nous donner des indications sur le type d'outil à utiliser. Il existe de nombreux outils sur le marché .NET permettant de profiler des applications .NET. Parmi ces outils :
- Memory Profiler : de la société Scitech, excellent mais très intrusif en terme d'empreinte
- Ants Profiler : de la société Red Gate, bon outil même si moins riche fonctionnellement que ces concurrents
- Dot Profiler : bon outil mais avec une empreinte importante
- CLR Profiler : outil de Microsoft, très pauvre d'un point de vue ergonomique, mais empreinte très faible
- WinDbg : outil Microsoft, ergonomie en mode console, empreinte infime, idéal pour l'aspect non intrusif
Cette liste non exhaustive a pour but de montrer qu'en fonction du type d'application analysée, l'intrusivité et l'empreinte de ces outils aura un impact indéniable sur le résultat. Plus l'ergonomie d'un outil sera riche (graphiques complexes, snapshot avec delta, etc .), plus cet outil occupera de la mémoire. N'oubliez pas que le but d'une analyse de fuite est justement de monitorer une application qui occupe à l'origine de plus en plus de mémoire. Ce monitoring s'exerçant souvent sur la même machine, celle-ci devient de plus en plus instable au fil des fuites et tout outil externe a tendance a encore plus ralentir le processus de collecte.
Il existe deux manière de collecter des informations de profiling mémoire :
- soit en lançant un outil d'instrumentation de byte-code qui héberge et pilote l'application qui fuit (intrusif et gourmand)
- soit en s'attachant au processus de l'application et en générant des dumps mémoire (moins intrusif, mais plus complexe à analyser)
La plupart de ces outils proposent les deux modes. Mais certains types d'applications (ASP.NET) ne pourront être hostés. D'où le tri dans la liste précédente.
Il existe également plusieurs modèles de fuite :
- la fuite caractérisée : elle est régulière au gré de l'exécution nominale d'une application. J'ai l'habitude de dire que ce type de fuite prend 5 mn pour être corrigée. Malheureusement, ce modèle de fuite n'est pas celle qu'on rencontre le plus fréquemment.
- la fuite conditionnée : celle-ci n'apparaît que dans un cas d'utilisation très précis de l'application. Pour la détecter, il faut maîtriser le métier de l'application et monitorer en permanence l'activité jusqu'à déceler le cas précis. Ensuite seulement pourra démarrer l'activité d'audit.
- la fuite de type goutte d'eau : telle une goutte d'eau, cette fuite apparaît au bout de plusieurs jours d'activité. Non seulement elle est infime, mais elle peut avoir des origines diverses (plusieurs types de classes fuient en quantité faible). Ce cas d'école est classique et on arrive souvent à colmater les brèches par des opérations curatives (using/dispose/close). En revanche, il est très difficile de l'éradiquer complètement car la fuite provient parfois du Framework .NET lui même (la preuve) ou de fournisseur externes (drivers ADO.NET)
- la fuite sournoise : c'est la pire, celle traitée dans cet article. Elle se produit par un concours de circonstances tellement complexe que les outils actuels sont bien incapables de les identifier de manière précise. On croit mettre le doigt sur une accumulation d'un type de références non libérées à un instant T, mais celles-ci peuvent provenir d'un Thread externe en attente d'information dans un DeadLock.
Un exemple classique de fuite
Voici un exemple classique de code provoquant une fuite. N'importe quel développeur, même très compétent, pourrait en être l'auteur.
L'idée est d'ouvrir un formulaire (Form2) qui reçoit des informations de modification de texte à partir d'un formulaire 1 (Form1).
Le code source de Form1 consiste simplement à gérer un évènement button1_Click qui crée le Form2 sans oublier de passer une référence de Form2 à Form1.
namespace LeakApp
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
private void button1_Click(object sender, EventArgs e)
{
Form2 dialog = new Form2();
// Le form2 a besoin d'une référence vers form1 pour récupérer un évènement particulier
dialog.init(this);
dialog.Show();
}
private void button2_Click(object sender, EventArgs e)
{
GC.Collect();
}
}
}
Pour Form2, le code consiste simplement à s’abonner à l’évènement TextChanged() de Form1 sur TextBox. Voici le code :
public partial class Form2 : Form
{
Form1 f1;
public Form2()
{
InitializeComponent();
}
public void init(Form1 f)
{
f1 = f;
f1.TextBox.TextChanged += new EventHandler(textBox_TextChanged);
}
void textBox_TextChanged(object sender, EventArgs e)
{
label1.Text = ((TextBox)sender).Text;
}
private void close_Click(object sender, EventArgs e)
{
this.Dispose();
}
}
Aussi étonnant que cela puisse paraître, ce code d'apparence anodine fuit comme un panier percé. Pour accentuer les effets de la fuite, j'ai simplement ajouté un contrôle PictureBox sur Form2 (une copie d'écran au format BMP). Cette PictureBox représente une image d'environ 3 Mo.
Le gestionnaire des tâches suivant montre clairement la fuite. A chaque ouverture d'une fenêtre form2, 3 Mo supplémentaires sont alloués sur le tas et la fermeture de la fenêtre ne provoque aucune libération.
La méthodologie d'analyse et d'audit
La première étape dans pareil cas consiste à se munir des bons outils. Pour les cas les plus sérieux ma préférence va indéniablement au duo CLR Profiler et WinDbg. Lorsqu'une application fuit pour atteindre 300 Mo de mémoire, il faut compter autant de mémoire supplémentaire pour pouvoir l'analyser correctement. CLR Profiler est un outil conçu à l'origine par les équipes de la CLR. D'apparence très austère, ce produit gratuit est un petit bijou lorsqu'on sait s'en servir. Couplé à WinDbg, il permet de corréler graphiquement certains résultats d'analyse.
Illustrons la démarche d'audit avec WinDbg. La première action consiste à chercher le fautif. Sous WinDbg en mode SOS.dll, on tape la commande !dumpheap -stat.
Le résultat nous indique que plus de 30 Mo de notre application est constitué de 28 occurrences d'objets de type System.Byte[]. Le plus compliqué avec ce type de fuite est d'identifier si ces références sont bel et bien des fuites ou des pseudo-handles systèmes (le Framework .NET a tendance à générer parfois des sortes de caches d'objets internes).
Pour creuser, on demande le détail de ces allocations avec un !dumpheap -type System.Byte[].
Cette fois pas de doute, c'est une occurrence d'environ 3 Mo qui fuit.
En cas de fuite, le plus important consiste à rechercher la référence racine qui a la main vers l'objet incriminé. Pour cela un !gcroot nous permettra de tracer la chaîne de référence jusqu'à la racine. On s'aperçoit ici que l'image n'est pas libérée. Nous passons à CLR Profiler pour avoir un aperçu plus graphique.
Effectivement les objets Byte[] on trusté la mémoire. Avec un "Show Who Allocated", nous avons la possibilité de tracer dans le code les chemins menant vers l'allocation mémoire en cause.
Comme on peut le voir, la méthode onClick() de Form1 a provoqué l'appel à InitializeComponent() de Form2 qui a lui même invoqué le gestionnaire de ressources au travers du PictureBox. Les choses sont claires cette fois : Form2 n'est jamais libéré, malgré le Dispose().
Il faudra une recherche beaucoup plus approfondie pour identifier le coupable et la Root référence (qui se cache parfois dans des blocs natifs). L'appel à f1.TextBox.TextChanged += new EventHandler(textBox_TextChanged); induit une dépendance entre le contrôle TextBox de Form1 et cet évènement enregistré au niveau du MainThread. Le seul moyen de libérer Form1 est, soit de détruire le contrôle TextBox de Form2, soit plus simplement de désenregistrer l'évènement avec l'ordre f1.TextBox.TextChanged -= new EventHandler(textBox_TextChanged);.
A quel moment réaliser ce nettoyage ? Lors de la fermeture de la fenêtre Form2. Le simple ajout de la ligne de code précédente fait passer le programme de 100Mo à 17Mo après une dizaine d'ouverture de fenêtres.
public partial class Form2 : Form
{
Form1 f1;
public Form2()
{
InitializeComponent();
}
public void init(Form1 f)
{
f1 = f;
f1.TextBox.TextChanged += new EventHandler(textBox_TextChanged);
}
void textBox_TextChanged(object sender, EventArgs e)
{
label1.Text = ((TextBox)sender).Text;
}
private void close_Click(object sender, EventArgs e)
{
// Supprime la fuite
f1.textBox.TextChanged -= new EventHandler(textBox_TextChanged);
this.Dispose();
}
}
En Conclusion
Les fuites mémoire constituent l'un des plus grands dangers de la programmation basée sur des machines virtuelles. L'exemple précédent multiplié à une échelle de 10 voire 100 écrans peut avoir des effets ravageurs. Nombreux sont les projets qui négligent aujourd'hui cette gestion de la mémoire par souci d'économies budgétaires. D'autant plus que ces problèmes apparaissent souvent lors de la mise en production ou en phase d'intégration lorsqu'il est trop tard pour colmater les brèches.
Une bonne gestion de la mémoire doit passer d'une part par la formation des développeurs aux bonnes pratiques (Weak References, variables locales plutôt que globales, évitez les singletons à tout va, ..) et d'autre part par des revues de code régulières. Les Framework Client lourds mais aussi vectoriels (WPF ou Silverlight) regorgent de pièges à fuites (DependencyProperties plutôt que INotityPropertyChanged). Et je ne parle pas de Java, JavaScript ou même Flex. Il ne tient qu'à vous de les éviter.
Téléchargez le code source de LeakApp.zip
Liens utiles
http://blogs.msdn.com/jgoldb/archive/2008/02/04/finding-memory-leaks-in-wpf-based-applications.aspx
http://blog.ningzhang.org/2008/12/silverlight-debugging-with-windbg-and.html
http://geekswithblogs.net/sdorman/archive/2008/11/07/clr-4.0-garbage-collection-changes.aspx
Pour faire encore plus court, les fuites de mémoire (hors cochonneries dans du code unmanaged) :
0) Mauvaise durée de vie des objets (voir 2)
1) IDisposable.Dispose pas appelé (assez facile à traquer dans le source)
2) Membres statiques à la rue
3) Evénements sans désabonnement (illustré par ton exemple et le plus classique - et vicieux - en Winforms). A force de rencontrer ce problème j'en suis venu à implémenter dans l'objet qui expose les événements un Dispose qui vire sauvagement tous les délégués qui sont abonnés à cette instance. Bizarrement je n'ai pas vu beaucoup d'usage de cette tactique, je me demande du coup s'il y a pas un vice caché quelque part.
Ah, sinon, dans windbg,
!dumpheap -mt [mt] (où mt est la MethodTable qui se trouve sur la première colonne quand on fait un !dumpheap -stat et qui comme son nom l'indique, euh, représente l'identifiant unique de la classe)
est nettement préférable à
!dumpheap -type Toto
qui fait une recherche par sous-chaine sur tous les types du tas (beaucoup, beaucoup plus lent)
C'était juste pour pinailler, ton article est excellent.
Un petit parallèle avec Java et ses outils eut été le bienvenu mais bon :p
Genre de petites choses qu'on ne pense pas forcément au moment d'écrire son code. Il faudrait presque des check list de bonnes pratiques accolée sur le mur.
Un autre lien sur une expérience similaire : http://jclabaut.free.fr/serendipity/index.php?/archives/54-Asp.net-memory-leak-case-study-Asp.net-fuite-memoire.html
Au passage le bog de Tess Fernandez, une référence pour le dépistage de fuites mémoire: http://blogs.msdn.com/tess/archive/tags/Memory+issues/default.aspx
Détecter et éviter les fuites de mémoire et de ressources dans les applications .NET
http://dotnetguru.org/modules.php?op=modload&name=News&file=article&sid=1283
Fabrice