Rex – AngularJS et les Fuites mémoires
Il y a peu, j’ai eu le privilège de travailler sur un très gros projet AngularJS. Et par très gros j’entends : démesuré, du genre qui pousse le framework dans ses derniers retranchements. Un projet où la limite « officielle » du nombre de 2000 bindings pour obtenir des performances acceptables (pour les versions <1.3 du moins) se compare avec les 70 000 bindings d’une seule page comportant 1Go de données.
Les fuites mémoires, sont déjà des phénomènes difficiles à résoudre, mais dans un tel contexte, cela devient un véritable cauchemar ! Voici donc un retour d’expérience sur la chasse aux fuites mémoires sur un projet AngularJS.
La littérature évoque généralement deux approches :
La méthode “Généticienne” : A base de relecture de code
Effectivement, l’ADN de votre appli contient probablement des anomalies répertoriées (fuites mémoires ou non). L’idée n’est pas nécessairement de repasser sur tout le code mais de se focaliser sur certaines instructions ou patterns clés ($timeout
, .on()
, $scope.$watch
, console.log
…)
Pour être vraiment efficace, mieux vaut maîtriser le framework et connaître les erreurs fréquentes que vous pouvez rencontrer ici ou là.
Mais quand on est malade, le problème peut aussi être environnemental : vos dépendances peuvent elles aussi être problématiques. C’est là que la seconde solution s’impose bien souvent : plutôt que d’inspecter tous les tuyaux à la recherche d’un trou, partons de la flaque.
La méthode “Voyons ce qui coule” : avec les outils de profiling de Chrome
L’outil de profiling de Chrome est une vrai richesse. Il fait tout ce qu’on pourrait attendre d’un profiler ! Suivi de la consommation mémoire, dumps mémoire, comparaison de dumps… Tout y est.
Malgré l’excellent outil, je me suis heurté à plusieurs problèmes :
- Le Profiler a besoin de beaucoup de ressources (CPU et mémoire) et dans mon cas de saturation, l’obtention d’un dump n’était pas toujours assurée.
- Les dumps sont difficiles à lire. Les scopes sont souvent les objets les plus chargés en données, ils sont très fréquemment créés puis détruits puis recréés au fil de la navigation. Dans ces conditions, ce sont eux, en particulier, qui risquent de poser problème. Mais ils sont aussi tous très liés entre eux et leurs liens (parents, enfants, frères) sont bidirectionnels ! Dans un gros dump, on peut identifier des scopes qui ne devraient plus être en mémoire, mais difficilement le lien qui les retient.
- Certaines fuites mémoires sont “faites exprès” ! Il s’agit de cache applicatif, de cache angularJS (http, template…), de cache JQuery/jqLite (DOM, nodes, http…), de rétention pour les outils (Batarang, console.log…), du cache navigateur (DOM). Tout cela vient rapidement polluer l’analyse.
- Le Garbage Collector de Chrome est assez paresseux et peu prédictible. Il n’est pas impossible qu’il tarde à supprimer tel ou tel type d’objet ou qu’il ne fasse pas tout le boulot à chaque fois… J’ai dû abandonner toutes mes analyses avec plein de beaux screenshots colorés basées sur la timeline. Le bouton lançant le GC n’est pas non plus une garantie.
Rétrospectivement, je ne fais plus confiance qu’aux dumps (en vue comparé ou non) : en recherchant les éléments de type $get.Scope.$new.$$childScopeClass
, vous pourrez probablement trouver une accumulation de certaines familles de scope.
Aussi, à force de naviguer au hasard des dumps, je suis tombé sur un ‘retainer’ récurrent répondant au doux nom de jQuery1830550544549478218
.
J’ai alors découvert une troisième approche :
La méthode “des Expandos” :
Avant de décrire la méthode, arrêtons nous un instant, sur un détail d’implémentation de JQuery (reproduit dans jqLite). JQuery comme jqLite ajoutent des données aux nœuds du DOM en guise de cache. Il s’agit grosso modo de profiter que chaque nœud soit un objet JavaScript qui peut, par nature, être étendu par de nouvelles propriétés arbitraires (appelées “expando properties”).
Lorsqu’on ajoute un gestionnaire d’événement comme ceci :
angular.element($window.document.body).on("mousedown",maFunction);
ou
$("body").on("mousedown",maFonction);
La lib JS va persister l’association nœud/événement/fonction sous forme d’expando sur le nœud. Ici, elle ajoutera sur l’élément body
une propriété, respectivement, ng135
et jQuery1830550544549478218
. Ainsi, la fonction est liée à un élément du DOM. Comme dans cet exemple (avec le nœud body
), certains nœuds du DOM n’ont pas pour vocation à être supprimés. Si au fil de la navigation, on ajoute toujours les mêmes fonctions au même nœud liant des scopes toujours recréés, il y a donc une fuite mémoire.
Fort de ce constat, j’ai développé un script permettant de parcourir tous les nœuds du DOM et de vérifier chaque propriété du type jQuery* ou ng* pour comptabiliser ces liens nœud/fonction.
Voici comment l’utiliser :
- Lancer l’appli
- Dérouler un scénario de navigation en passant au moins deux fois par des pages/vues à tester (pour initier la redondance provoquée par la fuite mémoire)
- Ouvrir les outils de débug Chrome
- Lancer le script (depuis la console, les snipnets ou un bookmarklet
- Le script va lister les fonctions liées plus d’une fois à un même nœud
- Il vous en affichera le code :
- Il ne reste plus qu’à trouver, dans la base de code, cette fonction et corriger son usage
Vous trouverez :
- une page de démonstration : http://mmouterde.github.io/ngLeak
- le code du script : https://github.com/mmouterde/ngLeak
- le plunker associé : http://plnkr.co/edit/fsxEym?p=previ…
Si ce type de fuite mémoire n’est bien sur pas le seul type possible, c’est celui que j’ai rencontré le plus souvent que ce soit sur le projet ou sur ses dépendances.
Pour conclure, notez que chaque méthode présentée ici comporte un intérêt, ne vous arrêtez pas à un seul outil. Pensez à tester votre appli régulièrement sur ces aspects de fuites mémoires, surtout lors de l’intégration de lib extérieure.