Traquer les problèmes de mémoire dans Ruby : un guide définitif

Publié: 2022-03-11

Je suis sûr qu'il y a des développeurs Ruby chanceux qui ne rencontreront jamais de problèmes de mémoire, mais pour le reste d'entre nous, il est incroyablement difficile de rechercher où l'utilisation de la mémoire devient incontrôlable et de le réparer. Heureusement, si vous utilisez un Ruby moderne (2.1+), il existe d'excellents outils et techniques disponibles pour traiter les problèmes courants. On pourrait également dire que l'optimisation de la mémoire peut être amusante et enrichissante, bien que je sois peut-être seul dans ce sentiment.

Traquer les problèmes de mémoire dans Ruby

Si vous pensiez que les bogues étaient embêtants, attendez de rechercher des problèmes de mémoire.
Tweeter

Comme pour toutes les formes d'optimisation, il y a de fortes chances que cela ajoute de la complexité au code, donc cela ne vaut pas la peine de le faire à moins qu'il y ait des gains mesurables et significatifs.

Tout ce qui est décrit ici est fait à l'aide de l'IRM Ruby canonique, version 2.2.4, bien que les autres versions 2.1+ devraient se comporter de la même manière.

Ce n'est pas une fuite de mémoire !

Lorsqu'un problème de mémoire est découvert, il est facile de conclure qu'il y a une fuite de mémoire. Par exemple, dans une application Web, vous pouvez constater qu'après avoir démarré votre serveur, des appels répétés vers le même point de terminaison continuent d'augmenter l'utilisation de la mémoire à chaque requête. Il y a certainement des cas où des fuites de mémoire légitimes se produisent, mais je parierais qu'elles sont largement dépassées en nombre par des problèmes de mémoire avec cette même apparence qui ne sont pas réellement des fuites.

À titre d'exemple (artificiel), examinons un peu de code Ruby qui construit à plusieurs reprises un grand tableau de hachages et le supprime. Tout d'abord, voici du code qui sera partagé dans les exemples de cet article :

 # common.rb require "active_record" require "active_support/all" require "get_process_mem" require "sqlite3" ActiveRecord::Base.establish_connection( adapter: "sqlite3", database: "people.sqlite3" ) class Person < ActiveRecord::Base; end def print_usage(description) mb = GetProcessMem.new.mb puts "#{ description } - MEMORY USAGE(MB): #{ mb.round }" end def print_usage_before_and_after print_usage("Before") yield print_usage("After") end def random_name (0...20).map { (97 + rand(26)).chr }.join end

Et le générateur de tableau :

 # build_arrays.rb require_relative "./common" ARRAY_SIZE = 1_000_000 times = ARGV.first.to_i print_usage(0) (1..times).each do |n| foo = [] ARRAY_SIZE.times { foo << {some: "stuff"} } print_usage(n) end

La gemme get_process_mem est juste un moyen pratique d'obtenir la mémoire utilisée par le processus Ruby actuel. Ce que nous voyons est le même comportement que celui décrit ci-dessus, une augmentation continue de l'utilisation de la mémoire.

 $ ruby build_arrays.rb 10 0 - MEMORY USAGE(MB): 17 1 - MEMORY USAGE(MB): 330 2 - MEMORY USAGE(MB): 481 3 - MEMORY USAGE(MB): 492 4 - MEMORY USAGE(MB): 559 5 - MEMORY USAGE(MB): 584 6 - MEMORY USAGE(MB): 588 7 - MEMORY USAGE(MB): 591 8 - MEMORY USAGE(MB): 603 9 - MEMORY USAGE(MB): 613 10 - MEMORY USAGE(MB): 621

Cependant, si nous exécutons plus d'itérations, nous finirons par atteindre un plateau.

 $ ruby build_arrays.rb 40 0 - MEMORY USAGE(MB): 9 1 - MEMORY USAGE(MB): 323 ... 32 - MEMORY USAGE(MB): 700 33 - MEMORY USAGE(MB): 699 34 - MEMORY USAGE(MB): 698 35 - MEMORY USAGE(MB): 698 36 - MEMORY USAGE(MB): 696 37 - MEMORY USAGE(MB): 696 38 - MEMORY USAGE(MB): 696 39 - MEMORY USAGE(MB): 701 40 - MEMORY USAGE(MB): 697

Atteindre ce plateau est la marque de ne pas être une fuite de mémoire réelle, ou que la fuite de mémoire est si petite qu'elle n'est pas visible par rapport au reste de l'utilisation de la mémoire. Ce qui n'est peut-être pas intuitif, c'est pourquoi l'utilisation de la mémoire continue de croître après la première itération. Après tout, il a construit un grand tableau, mais l'a rapidement jeté et a commencé à en construire un nouveau de la même taille. Ne peut-il pas simplement utiliser l'espace libéré par le tableau précédent ? La réponse, qui explique notre problème, est non. Mis à part le réglage du ramasse-miettes, vous n'avez aucun contrôle sur le moment où il s'exécute, et ce que nous voyons dans l'exemple build_arrays.rb , ce sont de nouvelles allocations de mémoire effectuées avant le ramasse-miettes de nos anciens objets abandonnés.

Ne paniquez pas si vous constatez une augmentation soudaine de l'utilisation de la mémoire de votre application. Les applications peuvent manquer de mémoire pour toutes sortes de raisons - pas seulement des fuites de mémoire.

Je dois souligner qu'il ne s'agit pas d'une sorte d'horrible problème de gestion de la mémoire spécifique à Ruby, mais qu'il s'applique généralement aux langages récupérés. Juste pour me rassurer, j'ai reproduit essentiellement le même exemple avec Go et j'ai vu des résultats similaires. Cependant, il existe des bibliothèques Ruby qui facilitent la création de ce type de problème de mémoire.

Diviser et conquérir

Donc, si nous devons travailler avec de gros volumes de données, sommes-nous condamnés à consacrer beaucoup de RAM à notre problème ? Heureusement, ce n'est pas le cas. Si nous prenons l'exemple build_arrays.rb et réduisons la taille du tableau, nous verrons une diminution du point où l'utilisation de la mémoire plafonne qui est à peu près proportionnelle à la taille du tableau.

Cela signifie que si nous pouvons diviser notre travail en plus petits morceaux à traiter et éviter d'avoir trop d'objets existants en même temps, nous pouvons réduire considérablement l'empreinte mémoire. Malheureusement, cela signifie souvent prendre un code agréable et propre et le transformer en plus de code qui fait la même chose, juste d'une manière plus économe en mémoire.

Isolation des hotspots d'utilisation de la mémoire

Dans une vraie base de code, la source d'un problème de mémoire ne sera probablement pas aussi évidente que dans l'exemple build_arrays.rb . Il est essentiel d'isoler un problème de mémoire avant d'essayer de creuser et de le résoudre, car il est facile de faire des hypothèses erronées sur la cause du problème.

J'utilise généralement deux approches, souvent en combinaison, pour traquer les problèmes de mémoire : laisser le code intact et l'envelopper d'un profileur, et surveiller l'utilisation de la mémoire du processus tout en désactivant/activant différentes parties du code qui, je pense, pourraient être problématiques. J'utiliserai ici memory_profiler pour le profilage, mais ruby-prof est une autre option populaire, et derailed_benchmarks a d'excellentes capacités spécifiques à Rails.

Voici un code qui utilisera beaucoup de mémoire, où il se peut que l'on ne sache pas immédiatement quelle étape augmente le plus l'utilisation de la mémoire :

 # people.rb require_relative "./common" def run(number) Person.delete_all names = number.times.map { random_name } names.each do |name| Person.create(name: name) end records = Person.all.to_a File.open("people.txt", "w") { |out| out << records.to_json } end

En utilisant get_process_mem, nous pouvons rapidement vérifier qu'il utilise beaucoup de mémoire lorsqu'il y a beaucoup d'enregistrements Person en cours de création.

 # before_and_after.rb require_relative "./people" print_usage_before_and_after do run(ARGV.shift.to_i) end

Résultat:

 $ ruby before_and_after.rb 10000 Before - MEMORY USAGE(MB): 37 After - MEMORY USAGE(MB): 96

En parcourant le code, il y a plusieurs étapes qui semblent être de bons candidats pour utiliser beaucoup de mémoire : construire un grand tableau de chaînes, appeler #to_a sur une relation Active Record pour créer un grand tableau d'objets Active Record (pas une bonne idée , mais à des fins de démonstration) et sérialiser le tableau d'objets Active Record.

Nous pouvons ensuite profiler ce code pour voir où se produisent les allocations de mémoire :

 # profile.rb require "memory_profiler" require_relative "./people" report = MemoryProfiler.report do run(1000) end report.pretty_print(to_file: "profile.txt")

Notez que le nombre alimenté pour run ici est 1/10 de l'exemple précédent, car le profileur lui-même utilise beaucoup de mémoire et peut en fait entraîner un épuisement de la mémoire lors du profilage de code qui entraîne déjà une utilisation élevée de la mémoire.

Le fichier de résultats est assez long et inclut l'allocation et la rétention de mémoire et d'objets aux niveaux de la gemme, du fichier et de l'emplacement. Il y a une mine d'informations à explorer, mais voici quelques extraits intéressants :

 allocated memory by gem ----------------------------------- 17520444 activerecord-4.2.6 7305511 activesupport-4.2.6 2551797 activemodel-4.2.6 2171660 arel-6.0.3 2002249 sqlite3-1.3.11 ... allocated memory by file ----------------------------------- 2840000 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activesupport-4.2.6/lib/activ e_support/hash_with_indifferent_access.rb 2006169 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activerecord-4.2.6/lib/active _record/type/time_value.rb 2001914 /Users/bruz/code/mem_test/people.rb 1655493 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activerecord-4.2.6/lib/active _record/connection_adapters/sqlite3_adapter.rb 1628392 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activesupport-4.2.6/lib/activ e_support/json/encoding.rb

Nous voyons la plupart des allocations se produire à l'intérieur d'Active Record, ce qui semble indiquer soit l'instanciation de tous les objets du tableau d' records , soit la sérialisation avec #to_json . Ensuite, nous pouvons tester notre utilisation de la mémoire sans le profileur tout en désactivant ces suspects. Nous ne pouvons pas désactiver la récupération records et continuer à effectuer l'étape de sérialisation, alors essayons d'abord de désactiver la sérialisation.

 # File.open("people.txt", "w") { |out| out << records.to_json }

Résultat:

 $ ruby before_and_after.rb 10000 Before: 36 MB After: 47 MB

Cela semble en effet être là où va la majeure partie de la mémoire, avec un delta de mémoire avant/après chutant de 81% en le sautant. Nous pouvons également voir ce qui se passe si nous arrêtons de forcer la création du grand tableau d'enregistrements.

 # records = Person.all.to_a records = Person.all # File.open("people.txt", "w") { |out| out << records.to_json }

Résultat:

 $ ruby before_and_after.rb 10000 Before: 36 MB After: 40 MB

Cela réduit également l'utilisation de la mémoire, bien qu'il s'agisse d'une réduction d'un ordre de grandeur inférieure à la désactivation de la sérialisation. Donc, à ce stade, nous connaissons nos principaux coupables et pouvons prendre une décision sur ce qu'il faut optimiser en fonction de ces données.

Bien que l'exemple ici ait été artificiel, les approches sont généralement applicables. Les résultats du profileur peuvent ne pas vous indiquer l'endroit exact dans votre code où se situe le problème, et peuvent également être mal interprétés, c'est donc une bonne idée de suivre en examinant l'utilisation réelle de la mémoire tout en activant et désactivant des sections de code. Ensuite, nous examinerons certains cas courants où l'utilisation de la mémoire devient un problème et comment les optimiser.

Désérialisation

Une source courante de problèmes de mémoire est la désérialisation de grandes quantités de données à partir de XML, JSON ou d'un autre format de sérialisation de données. L'utilisation de méthodes telles que JSON.parse ou Hash.from_xml d'Active Support est incroyablement pratique, mais lorsque les données que vous chargez sont volumineuses, la structure de données résultante chargée en mémoire peut être énorme.

Si vous avez le contrôle sur la source des données, vous pouvez faire des choses pour limiter la quantité de données que vous recevez, comme ajouter un filtrage ou une prise en charge de la pagination. Mais s'il s'agit d'une source externe ou que vous ne pouvez pas contrôler, une autre option consiste à utiliser un désérialiseur de streaming. Pour XML, Ox est une option, et pour JSON, yajl-ruby semble fonctionner de la même manière, bien que je n'aie pas beaucoup d'expérience avec cela.

Ce n'est pas parce que vous avez une mémoire limitée que vous ne pouvez pas analyser de gros documents XML ou JSON en toute sécurité. Les désérialiseurs de streaming vous permettent d'extraire progressivement tout ce dont vous avez besoin de ces documents tout en maintenant une faible empreinte mémoire.

Voici un exemple d'analyse d'un fichier XML de 1,7 Mo à l'aide de Hash#from_xml .

 # parse_with_from_xml.rb require_relative "./common" print_usage_before_and_after do # From http://www.cs.washington.edu/research/xmldatasets/data/mondial/mondial-3.0.xml file = File.open(File.expand_path("../mondial-3.0.xml", __FILE__)) hash = Hash.from_xml(file)["mondial"]["continent"] puts hash.map { |c| c["name"] }.join(", ") end
 $ ruby parse_with_from_xml.rb Before - MEMORY USAGE(MB): 37 Europe, Asia, America, Australia/Oceania, Africa After - MEMORY USAGE(MB): 164

111 Mo pour un fichier de 1,7 Mo ! Cela ne va clairement pas bien évoluer. Voici la version de l'analyseur de flux.

 # parse_with_ox.rb require_relative "./common" require "ox" class Handler < ::Ox::Sax def initialize(&block) @yield_to = block end def start_element(name) case name when :continent @in_continent = true end end def end_element(name) case name when :continent @yield_to.call(@name) if @name @in_continent = false @name = nil end end def attr(name, value) case name when :name @name = value if @in_continent end end end print_usage_before_and_after do # From http://www.cs.washington.edu/research/xmldatasets/data/mondial/mondial-3.0.xml file = File.open(File.expand_path("../mondial-3.0.xml", __FILE__)) continents = [] handler = Handler.new do |continent| continents << continent end Ox.sax_parse(handler, file) puts continents.join(", ") end
 $ ruby parse_with_ox.rb Before - MEMORY USAGE(MB): 37 Europe, Asia, America, Australia/Oceania, Africa After - MEMORY USAGE(MB): 37

Cela nous ramène à une augmentation négligeable de la mémoire et devrait être capable de gérer des fichiers beaucoup plus volumineux. Cependant, le compromis est que nous avons maintenant 28 lignes de code de gestionnaire dont nous n'avions pas besoin auparavant, ce qui semble être sujet aux erreurs, et pour une utilisation en production, il devrait y avoir des tests autour.

Sérialisation

Comme nous l'avons vu dans la section sur l'isolation des points chauds d'utilisation de la mémoire, la sérialisation peut avoir des coûts de mémoire élevés. Voici la partie clé de people.rb de tout à l'heure.

 # to_json.rb require_relative "./common" print_usage_before_and_after do File.open("people.txt", "w") { |out| out << Person.all.to_json } end

En exécutant ceci avec 100 000 enregistrements dans la base de données, nous obtenons :

 $ ruby to_json.rb Before: 36 MB After: 505 MB

Le problème avec l'appel de #to_json ici est qu'il instancie un objet pour chaque enregistrement, puis encode en JSON. La génération du JSON enregistrement par enregistrement afin qu'un seul objet d'enregistrement ait besoin d'exister à la fois réduit considérablement l'utilisation de la mémoire. Aucune des bibliothèques Ruby JSON populaires ne semble gérer cela, mais une approche généralement recommandée consiste à créer la chaîne JSON manuellement. Il existe une gemme json-write-stream qui fournit une belle API pour ce faire, et la conversion de notre exemple ressemble à :

 # json_stream.rb require_relative "./common" require "json-write-stream" print_usage_before_and_after do file = File.open("people.txt", "w") JsonWriteStream.from_stream(file) do |writer| writer.write_object do |obj_writer| obj_writer.write_array("people") do |arr_writer| Person.find_each do |people| arr_writer.write_element people.as_json end end end end end

Encore une fois, nous voyons que l'optimisation nous a donné plus de code, mais le résultat semble en valoir la peine :

 $ ruby json_stream.rb Before: 36 MB After: 56 MB

Être paresseux

Une fonctionnalité intéressante ajoutée à Ruby à partir de la version 2.0 est la possibilité de rendre les enquêteurs paresseux. C'est très bien pour améliorer l'utilisation de la mémoire lors du chaînage des méthodes sur un énumérateur. Commençons par un code qui n'est pas paresseux :

 # not_lazy.rb require_relative "./common" number = ARGV.shift.to_i print_usage_before_and_after do names = number.times .map { random_name } .map { |name| name.capitalize } .map { |name| "#{ name } Jr." } .select { |name| name[0] == "X" } .to_a end

Résultat:

 $ ruby not_lazy.rb 1_000_000 Before: 36 MB After: 546 MB 

Ce qui se passe ici, c'est qu'à chaque étape de la chaîne, il itère sur chaque élément de l'énumérateur, produisant un tableau sur lequel la méthode suivante de la chaîne est invoquée, et ainsi de suite. Voyons ce qui se passe lorsque nous faisons cela paresseux, ce qui nécessite simplement d'ajouter un appel à lazy sur l'énumérateur que nous obtenons de times :

 # lazy.rb require_relative "./common" number = ARGV.shift.to_i print_usage_before_and_after do names = number.times.lazy .map { random_name } .map { |name| name.capitalize } .map { |name| "#{ name } Jr." } .select { |name| name[0] == "X" } .to_a end

Résultat:

 $ ruby lazy.rb 1_000_000 Before: 36 MB After: 52 MB

Enfin, un exemple qui nous donne un énorme gain d'utilisation de la mémoire, sans ajouter beaucoup de code supplémentaire ! Notez que si nous n'avions pas besoin d'accumuler de résultats à la fin, par exemple, si chaque élément était enregistré dans la base de données et pouvait ensuite être oublié, il y aurait encore moins d'utilisation de la mémoire. Pour faire une évaluation énumérable paresseuse à la fin de la chaîne, ajoutez simplement un dernier appel à force .

Une autre chose à noter à propos de l'exemple est que la chaîne commence par un appel à times avant lazy , qui utilise très peu de mémoire puisqu'il renvoie simplement un énumérateur qui générera un entier à chaque fois qu'il est invoqué. Donc, si un énumérable peut être utilisé à la place d'un grand tableau au début de la chaîne, cela aidera.

Tout conserver dans d'énormes tableaux et cartes est pratique, mais dans des scénarios réels, vous avez rarement besoin de le faire.

Une application réelle de la construction d'un énumérable pour alimenter paresseusement une sorte de pipeline de traitement consiste à traiter des données paginées. Ainsi, plutôt que de demander toutes les pages et de les mettre dans un seul grand tableau, elles pourraient être exposées via un énumérateur qui cache bien tous les détails de pagination. Cela pourrait ressembler à quelque chose comme :

 def records Enumerator.new do |yielder| has_more = true page = 1 while has_more response = fetch(page) response.records.each { |record| yielder << record } page += 1 has_more = response.has_more end end end

Conclusion

Nous avons effectué une caractérisation de l'utilisation de la mémoire dans Ruby et examiné certains outils généraux permettant de détecter les problèmes de mémoire, ainsi que certains cas courants et des moyens de les améliorer. Les cas courants que nous avons explorés ne sont en aucun cas exhaustifs et sont fortement biaisés par le type de problèmes que j'ai personnellement rencontrés. Cependant, le gain le plus important consiste peut-être simplement à penser à l'impact du code sur l'utilisation de la mémoire.

Connexes : Concurrence et parallélisme Ruby : un didacticiel pratique