Trois types de fuites de mémoire

Bonjour chers collègues.

Notre longue recherche de livres intemporels à succès sur l'optimisation du code n'a donné que des premiers résultats, mais nous sommes prêts à vous plaire que la traduction du livre légendaire de Ben Watson " Writing High Performance .NET Code " vient littéralement d'être terminée. Dans les magasins - provisoirement en avril, surveillez la publicité.

Et aujourd'hui, nous vous proposons de lire un article purement pratique sur les types de fuites de mémoire les plus urgents, écrit par Nelson Ilheidzhe (Strike).

Donc, vous avez un programme qui prend plus de temps à terminer, plus il prend de temps. Il ne sera probablement pas difficile pour vous de comprendre qu'il s'agit d'un signe certain d'une fuite de mémoire.
Cependant, qu'entendons-nous exactement par «fuite de mémoire»? D'après mon expérience, les fuites de mémoire explicites sont divisées en trois catégories principales, chacune caractérisée par un comportement spécial, et pour le débogage de chacune des catégories, des outils et techniques spéciaux sont nécessaires. Dans cet article, je veux décrire les trois classes et suggérer comment reconnaître correctement, avec
à quelle classe vous avez affaire et comment trouver une fuite.

Type (1): fragment de mémoire inaccessible alloué

Il s'agit d'une fuite de mémoire classique en C / C ++. Quelqu'un a alloué de la mémoire en utilisant new ou malloc , et n'a pas appelé free ou delete pour libérer de la mémoire après avoir fini de l'utiliser.

 void leak_memory() { char *leaked = malloc(4096); use_a_buffer(leaked); /* ,   free() */ } 

Comment déterminer si une fuite appartient à cette catégorie

  • Si vous écrivez en C ou C ++, en particulier en C ++ sans l'utilisation généralisée de pointeurs intelligents pour contrôler la durée de vie des segments de mémoire, alors c'est l'option que nous envisageons en premier.
  • Si le programme s'exécute dans un environnement avec garbage collection, il est possible qu'une fuite de ce type soit provoquée par une extension de code natif , cependant, les fuites des types (2) et (3) doivent d'abord être éliminées.

Comment trouver une telle fuite

  • Utilisez ASAN . Utilisez ASAN. Utilisez ASAN.
  • Utilisez un autre détecteur. J'ai essayé les outils Valgrind ou tcmalloc pour travailler avec un groupe, il existe également d'autres outils dans d'autres environnements.
  • Certains allocateurs de mémoire permettent de vider le profil de tas, qui affichera toutes les zones de mémoire non allouées. Si vous avez une fuite, après un certain temps, presque toutes les décharges actives en découleront, il n'est donc probablement pas difficile de la trouver.
  • Si tout le reste échoue, videz un vidage de mémoire et examinez-le aussi méticuleusement que possible . Mais ne devrait certainement pas commencer par cela.

Type (2): allocations de mémoire non planifiées à longue durée de vie

De telles situations ne sont pas des «fuites» au sens classique du terme, car un lien de quelque part vers cette mémoire est toujours conservé, donc à la fin il peut être libéré (si le programme parvient à y arriver sans utiliser toute la mémoire).
Des situations dans cette catégorie peuvent survenir pour de nombreuses raisons spécifiques. Les plus courants sont:

  • Accumulation involontaire d'état dans une structure globale; par exemple, le serveur HTTP écrit dans la liste globale chaque objet Request reçu.
  • Caches sans politique d'obsolescence bien pensée. Par exemple, un cache ORM qui met en cache chaque objet chargé unique, actif pendant la migration, dans lequel tous les enregistrements présents dans la table sont chargés sans exception.
  • Un état trop volumineux est capturé dans le circuit. Ce cas est particulièrement courant dans Java Script, mais il peut également se produire dans d'autres environnements.
  • Dans un sens plus large, la rétention par inadvertance de chaque élément d'un tableau ou d'un flux, alors qu'il était supposé que ces éléments seraient traités en streaming en ligne.

Comment déterminer si une fuite appartient à cette catégorie

  • Si le programme s'exécute dans un environnement avec garbage collection, alors c'est l'option que nous envisageons en premier.
  • Comparez la taille du tas affichée dans les statistiques du garbage collector avec la taille de la mémoire libre générée par le système d'exploitation. Si une fuite tombe dans cette catégorie, les chiffres seront comparables et, surtout, se suivront au fil du temps.

Comment trouver une telle fuite

Utilisez les profileurs ou les outils de vidage de tas disponibles dans votre environnement. Je sais qu'il y a guppy en Python ou memory_profiler dans Ruby, et j'ai également écrit ObjectSpace directement dans Ruby.

Type (3): mémoire libre mais inutilisée ou inutilisable

Cette catégorie est la plus difficile à caractériser, mais c'est précisément sa plus importante à comprendre et à prendre en compte.

Des fuites de ce type se produisent dans la zone grise, entre la mémoire considérée comme «libre» du point de vue de l'allocateur à l'intérieur de la VM ou de l'environnement d'exécution, et la mémoire «libre» du point de vue du système d'exploitation. La raison la plus courante (mais pas la seule) de ce phénomène est la fragmentation du tas . Certains allocateurs prennent simplement et ne renvoient pas la mémoire au système d'exploitation une fois qu'elle a été allouée.

Un cas de ce type peut être considéré avec un exemple de programme court écrit en Python:

 import sys from guppy import hpy hp = hpy() def rss(): return 4096 * int(open('/proc/self/stat').read().split(' ')[23]) def gcsize(): return hp.heap().size rss0, gc0 = (rss(), gcsize()) buf = [bytearray(1024) for i in range(200*1024)] print("start rss={} gcsize={}".format(rss()-rss0, gcsize()-gc0)) buf = buf[::2] print("end rss={} gcsize={}".format(rss()-rss0, gcsize()-gc0)) 

Nous allouons 200 000 tampons de 1 ko, puis enregistrons chacun les suivants. Chaque seconde, nous affichons l'état de la mémoire du point de vue du système d'exploitation et du point de vue de notre propre ramasse-miettes Python.

Sur mon ordinateur portable, je reçois quelque chose comme ceci:

start rss=232222720 gcsize=11667592
end rss=232222720 gcsize=5769520


Nous pouvons nous assurer que Python a effectivement libéré la moitié des tampons, car le niveau gcsize a chuté de près de la moitié de la valeur de crête, mais n'a pas pu retourner un octet de cette mémoire au système d'exploitation. La mémoire libérée reste accessible au même processus Python, mais pas à tout autre processus sur cette machine.

De tels fragments de mémoire libres mais inutilisés peuvent être à la fois problématiques et inoffensifs. Si un programme Python agit de cette façon et alloue ensuite une poignée de fragments de 1 Ko, cet espace est simplement réutilisé et tout va bien.

Mais, si nous l'avons fait lors de la configuration initiale, et que nous avons ensuite alloué de la mémoire au minimum, ou si tous les fragments alloués par la suite étaient chacun de 1,5 Ko et ne correspondaient pas à ces tampons laissés à l'avance, alors toute la mémoire allouée de cette manière serait toujours inactive serait gaspillé.

Les problèmes de ce type sont particulièrement pertinents dans un environnement spécifique, à savoir dans les systèmes de serveur multiprocessus pour travailler avec des langages tels que Ruby ou Python.

Disons que nous avons mis en place un système dans lequel:

  • Sur chaque serveur, N travailleurs monothread sont utilisés pour traiter les demandes avec compétence. Prenons N = 10 pour la précision.
  • En règle générale, chaque employé a une quantité de mémoire presque constante. Pour plus de précision, prenons 500 Mo.
  • Avec une faible fréquence, nous recevons des demandes qui nécessitent beaucoup plus de mémoire que la demande médiane. Pour plus de précision, supposons qu'une fois par minute, nous obtenons une demande, dont le temps d'exécution nécessite en outre 1 Go de mémoire supplémentaire, et lorsque la demande est traitée, cette mémoire est libérée.

Une fois par minute, une telle demande de «cétacés» arrive, dont nous confions le traitement à l'un des 10 ouvriers, par exemple au hasard: ~random . Idéalement, lors du traitement de cette demande, cet employé doit allouer 1 Go de RAM, et après la fin du travail, retourner cette mémoire au système d'exploitation afin qu'elle puisse être réutilisée ultérieurement. Pour traiter les demandes de manière illimitée selon ce principe, le serveur n'aura besoin que de 10 * 500 Mo + 1 Go = 6 Go de RAM.

Cependant, supposons qu'en raison de la fragmentation ou pour une autre raison, la machine virtuelle ne pourra jamais retourner cette mémoire au système d'exploitation. C'est-à-dire que la quantité de RAM dont il a besoin du système d'exploitation est égale à la plus grande quantité de mémoire que vous ayez jamais à allouer à la fois. Dans ce cas, lorsqu'un employé particulier répond à une telle demande gourmande en ressources, la zone occupée par un tel processus en mémoire va pour toujours gonfler d'un gigaoctet entier.

Lorsque vous démarrez le serveur, vous verrez que la quantité de mémoire utilisée est de 10 * 500 Mo = 5 Go. Dès que la première grande demande arrive, le premier travailleur prend 1 Go de mémoire, puis ne le restitue pas. La quantité totale de mémoire utilisée passera à 6 Go. Les demandes entrantes suivantes peuvent parfois être redirigées vers le processus qui a précédemment traité la «baleine», auquel cas la quantité de mémoire utilisée ne changera pas. Mais parfois, une demande aussi importante sera livrée à un autre employé, à cause de quoi la mémoire sera gonflée pour un autre 1 Go, et ainsi de suite jusqu'à ce que chaque employé ait la possibilité de traiter une telle demande au moins une fois. Dans ce cas, vous prendrez jusqu'à 10 * (500 Mo + 1 Go) = 15 Go de RAM avec ces opérations, ce qui est bien plus que le 6 Go idéal! De plus, si vous regardez comment le parc de serveurs est utilisé au fil du temps, vous pouvez voir comment la quantité de mémoire utilisée augmente progressivement de 5 Go à 15 Go, ce qui rappellera très bien une «vraie» fuite.

Comment déterminer si une fuite appartient à cette catégorie

  • Comparez la taille du tas affichée dans les statistiques du garbage collector avec la taille de la mémoire libre générée par le système d'exploitation. Si la fuite appartient à cette (troisième) catégorie, les chiffres divergent au fil du temps.
  • J’aime configurer mes serveurs d’applications de manière à ce que ces deux nombres s’effondrent périodiquement dans mon infrastructure de séries chronologiques, il est donc pratique d’afficher des graphiques dessus.
  • Sous Linux, affichez l'état du système d'exploitation dans le champ 24 de /proc/self/stat et affichez l'allocateur de mémoire via une API spécifique au langage ou à la machine virtuelle.

Comment trouver une telle fuite

Comme déjà mentionné, cette catégorie est un peu plus insidieuse que les précédentes, car le problème se pose souvent même lorsque tous les composants fonctionnent «comme prévu». Cependant, il existe un certain nombre d'astuces utiles pour aider à atténuer ou à réduire l'impact de ces «fuites virtuelles»:

  • Redémarrez vos processus plus souvent. Si le problème se développe lentement, le redémarrage de tous les processus d'application une fois toutes les 15 minutes ou une fois par heure peut ne pas être difficile.
  • Une approche encore plus radicale: vous pouvez apprendre à tous les processus à redémarrer indépendamment, dès que l'espace qu'ils occupent en mémoire dépasse une certaine valeur seuil ou croît d'une valeur prédéterminée. Cependant, essayez de prévoir que l'ensemble de votre flotte de serveurs ne peut pas démarrer un redémarrage synchrone spontané.
  • Modifiez l'allocateur de mémoire. À long terme, tcmalloc et jemalloc gèrent généralement la fragmentation bien mieux que l'allocateur par défaut, et les expérimenter est très pratique en utilisant la LD_PRELOAD .
  • Découvrez si vous avez des requêtes individuelles qui consomment beaucoup plus de mémoire que les autres. Chez Stripe, nos serveurs API mesurent RSS (consommation de mémoire constante) avant et après avoir servi chaque requête API et enregistrent le delta. Ensuite, nous interrogeons facilement nos systèmes d'agrégation de journaux pour déterminer s'il existe de tels terminaux et utilisateurs (et modèles) qui peuvent être utilisés pour annuler des rafales de consommation de mémoire.
  • Ajustez le garbage collector / memory allocator. Beaucoup d'entre eux ont des paramètres personnalisables qui vous permettent de spécifier à quel point un tel mécanisme retournera de la mémoire au système d'exploitation, à quel point il est optimisé pour éliminer la fragmentation; il existe d'autres options utiles. Tout ici est également assez compliqué: assurez-vous de comprendre exactement ce que vous mesurez et optimisez, et essayez également de trouver un expert sur la machine virtuelle appropriée et de le consulter.

Source: https://habr.com/ru/post/fr432072/


All Articles