Dare la caccia ai problemi di memoria in Ruby: una guida definitiva

Pubblicato: 2022-03-11

Sono sicuro che ci sono alcuni fortunati sviluppatori Ruby là fuori che non avranno mai problemi con la memoria, ma per il resto di noi, è incredibilmente difficile dare la caccia a dove l'utilizzo della memoria sta sfuggendo di mano e risolverlo. Fortunatamente, se stai usando un moderno Ruby (2.1+), ci sono alcuni ottimi strumenti e tecniche disponibili per affrontare problemi comuni. Si potrebbe anche dire che l'ottimizzazione della memoria può essere divertente e gratificante anche se potrei essere solo in quel sentimento.

Dare la caccia ai problemi di memoria in Ruby

Se pensavi che i bug fossero fastidiosi, aspetta di cercare problemi di memoria.
Twitta

Come con tutte le forme di ottimizzazione, è probabile che aggiunga complessità al codice, quindi non vale la pena farlo a meno che non ci siano guadagni misurabili e significativi.

Tutto ciò che viene descritto qui viene eseguito utilizzando il canonico MRI Ruby, versione 2.2.4, sebbene le altre versioni 2.1+ dovrebbero comportarsi in modo simile.

Non è una perdita di memoria!

Quando viene rilevato un problema di memoria, è facile saltare alla conclusione che c'è una perdita di memoria. Ad esempio, in un'applicazione Web è possibile che dopo aver avviato il server, chiamate ripetute allo stesso endpoint continuano a aumentare l'utilizzo della memoria a ogni richiesta. Ci sono certamente casi in cui si verificano perdite di memoria legittime, ma scommetto che sono ampiamente superate in numero da problemi di memoria con questo stesso aspetto che in realtà non sono perdite.

Come esempio (artificioso), diamo un'occhiata a un po' di codice Ruby che crea ripetutamente una vasta gamma di hash e lo scarta. Innanzitutto, ecco del codice che verrà condiviso negli esempi in questo post:

 # 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

E il generatore di array:

 # 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 gem get_process_mem è solo un modo conveniente per ottenere la memoria utilizzata dall'attuale processo Ruby. Quello che vediamo è lo stesso comportamento descritto sopra, un continuo aumento dell'utilizzo della memoria.

 $ 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

Tuttavia, se eseguiamo più iterazioni, alla fine raggiungeremo il 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

Raggiungere questo plateau è il segno distintivo di non essere una perdita di memoria effettiva o che la perdita di memoria è così piccola da non essere visibile rispetto al resto dell'utilizzo della memoria. Ciò che potrebbe non essere intuitivo è il motivo per cui l'utilizzo della memoria continua a crescere dopo la prima iterazione. Dopotutto, ha costruito un grande array, ma poi lo ha prontamente scartato e ha iniziato a costruirne uno nuovo delle stesse dimensioni. Non può semplicemente utilizzare lo spazio liberato dall'array precedente? La risposta, che spiega il nostro problema, è no. A parte l'ottimizzazione del Garbage Collector, non hai il controllo su quando viene eseguito e ciò che vediamo nell'esempio build_arrays.rb sono nuove allocazioni di memoria effettuate prima della raccolta dei rifiuti dei nostri vecchi oggetti scartati.

Non farti prendere dal panico se vedi un improvviso aumento dell'utilizzo della memoria della tua app. Le app possono esaurire la memoria per diversi motivi, non solo per perdite di memoria.

Dovrei sottolineare che questa non è una sorta di orribile problema di gestione della memoria specifico di Ruby, ma è generalmente applicabile ai linguaggi di garbage collection. Giusto per rassicurarmi su questo, ho riprodotto essenzialmente lo stesso esempio con Go e ho visto risultati simili. Tuttavia, ci sono librerie Ruby che semplificano la creazione di questo tipo di problemi di memoria.

Dividere e conquistare

Quindi, se abbiamo bisogno di lavorare con grandi quantità di dati, siamo condannati a buttare solo molta RAM al nostro problema? Per fortuna, non è così. Se prendiamo l'esempio build_arrays.rb e riduciamo la dimensione dell'array, vedremo una diminuzione nel punto in cui l'utilizzo della memoria si stabilizza, che è approssimativamente proporzionale alla dimensione dell'array.

Ciò significa che se riusciamo a suddividere il nostro lavoro in parti più piccole da elaborare ed evitare di avere troppi oggetti esistenti contemporaneamente, possiamo ridurre drasticamente l'ingombro di memoria. Sfortunatamente, ciò significa spesso prendere codice pulito e pulito e trasformarlo in più codice che fa la stessa cosa, solo in un modo più efficiente in termini di memoria.

Isolamento degli hotspot di utilizzo della memoria

In una base di codice reale, l'origine di un problema di memoria probabilmente non sarà così ovvia come nell'esempio build_arrays.rb . Isolare un problema di memoria prima di provare a scavare e risolverlo è essenziale perché è facile fare supposizioni errate su ciò che sta causando il problema.

In genere utilizzo due approcci, spesso in combinazione, per rintracciare i problemi di memoria: lasciare il codice intatto e avvolgerlo attorno a un profiler e monitorare l'utilizzo della memoria del processo mentre si disabilitano/abilitano diverse parti del codice che sospetto possano essere problematiche. Userò memory_profiler qui per la profilazione, ma ruby-prof è un'altra opzione popolare e derailed_benchmarks ha alcune grandi capacità specifiche di Rails.

Ecco del codice che utilizzerà un sacco di memoria, in cui potrebbe non essere immediatamente chiaro quale passaggio sta aumentando maggiormente l'utilizzo della memoria:

 # 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

Usando get_process_mem, possiamo verificare rapidamente che utilizza molta memoria quando vengono creati molti record Person .

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

Risultato:

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

Esaminando il codice, ci sono più passaggi che sembrano buoni candidati per l'utilizzo di molta memoria: costruire una vasta gamma di stringhe, chiamare #to_a su una relazione di record attivo per creare una vasta gamma di oggetti di record attivi (non una grande idea , ma fatto a scopo dimostrativo) e serializzare l'array di oggetti Active Record.

Possiamo quindi profilare questo codice per vedere dove si verificano le allocazioni di memoria:

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

Si noti che il numero inserito per l' run qui è 1/10 dell'esempio precedente, poiché il profiler stesso utilizza molta memoria e può effettivamente portare all'esaurimento della memoria durante la profilazione del codice che già causa un utilizzo elevato della memoria.

Il file dei risultati è piuttosto lungo e include l'allocazione e la conservazione della memoria e degli oggetti a livello di gemma, file e posizione. C'è una grande quantità di informazioni da esplorare, ma ecco un paio di frammenti interessanti:

 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

Vediamo la maggior parte delle allocazioni che si verificano all'interno di Active Record, che sembrerebbe puntare all'istanziazione di tutti gli oggetti nell'array di records o alla serializzazione con #to_json . Successivamente, possiamo testare il nostro utilizzo della memoria senza il profiler disabilitando questi sospetti. Non possiamo disabilitare il recupero records ed essere comunque in grado di eseguire il passaggio di serializzazione, quindi proviamo prima a disabilitare la serializzazione.

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

Risultato:

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

Questo sembra effettivamente essere dove sta andando la maggior parte della memoria, con il delta della memoria prima / dopo che scende dell'81% saltandolo. Possiamo anche vedere cosa succede se smettiamo di forzare la creazione del grande array di record.

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

Risultato:

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

Ciò riduce anche l'utilizzo della memoria, sebbene sia un ordine di grandezza inferiore rispetto alla disabilitazione della serializzazione. Quindi, a questo punto, conosciamo i nostri maggiori colpevoli e possiamo prendere una decisione su cosa ottimizzare in base a questi dati.

Sebbene l'esempio qui sia stato inventato, gli approcci sono generalmente applicabili. I risultati del Profiler potrebbero non indirizzarti nel punto esatto del codice in cui si trova il problema e possono anche essere interpretati erroneamente, quindi è una buona idea continuare osservando l'utilizzo effettivo della memoria mentre si attivano e disattivano sezioni di codice. Successivamente, esamineremo alcuni casi comuni in cui l'utilizzo della memoria diventa un problema e come ottimizzarli.

Deserializzazione

Una fonte comune di problemi di memoria è la deserializzazione di grandi quantità di dati da XML, JSON o altri formati di serializzazione dei dati. L'uso di metodi come JSON.parse o Hash.from_xml di Active Support è incredibilmente conveniente, ma quando i dati che stai caricando sono di grandi dimensioni, la struttura dei dati risultante che viene caricata in memoria può essere enorme.

Se hai il controllo sull'origine dei dati, puoi fare delle cose per limitare la quantità di dati che stai ricevendo, come aggiungere filtri o supporto per l'impaginazione. Ma se è una fonte esterna o una che non puoi controllare, un'altra opzione è usare un deserializzatore di streaming. Per XML, Ox è un'opzione e per JSON yajl-ruby sembra funzionare in modo simile, anche se non ho molta esperienza con esso.

Solo perché hai una memoria limitata non significa che non puoi analizzare in modo sicuro documenti XML o JSON di grandi dimensioni. I deserializzatori in streaming ti consentono di estrarre in modo incrementale tutto ciò di cui hai bisogno da questi documenti e mantenere basso il footprint di memoria.

Ecco un esempio di analisi di un file XML da 1,7 MB, utilizzando 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 MB per un file da 1,7 MB! Questo chiaramente non aumenterà bene. Ecco la versione del parser in streaming.

 # 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

Questo ci porta a un aumento di memoria trascurabile e dovrebbe essere in grado di gestire file molto più grandi. Tuttavia, il compromesso è che ora abbiamo 28 righe di codice del gestore di cui non avevamo bisogno prima, il che sembra essere soggetto a errori e per l'uso in produzione dovrebbe avere alcuni test attorno ad esso.

Serializzazione

Come abbiamo visto nella sezione sull'isolamento degli hotspot di utilizzo della memoria, la serializzazione può avere costi di memoria elevati. Ecco la parte fondamentale di people.rb di prima.

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

Eseguendo questo con 100.000 record nel database, otteniamo:

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

Il problema con la chiamata di #to_json qui è che crea un'istanza di un oggetto per ogni record e quindi codifica in JSON. La generazione del record JSON record per record in modo che debba esistere un solo oggetto record alla volta riduce significativamente l'utilizzo della memoria. Nessuna delle popolari librerie JSON di Ruby sembra gestire questo, ma un approccio comunemente consigliato consiste nel creare manualmente la stringa JSON. C'è una gemma json-write-stream che fornisce una bella API per farlo, e convertire il nostro esempio in questo assomiglia a:

 # 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

Ancora una volta, vediamo che l'ottimizzazione ci ha fornito più codice, ma il risultato sembra valerne la pena:

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

Essere pigri

Una grande caratteristica aggiunta a Ruby a partire dalla 2.0 è la capacità di rendere pigri gli enumeratori. Questo è ottimo per migliorare l'utilizzo della memoria durante il concatenamento di metodi su un enumeratore. Iniziamo con un codice che non è pigro:

 # 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

Risultato:

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

Quello che succede qui è che ad ogni passaggio della catena, itera su ogni elemento nell'enumeratore, producendo un array che ha il metodo successivo nella catena invocato su di esso, e così via. Vediamo cosa succede quando rendiamo questo pigro, che richiede solo l'aggiunta di una chiamata a lazy sull'enumeratore che otteniamo da 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

Risultato:

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

Infine, un esempio che ci dà un enorme vantaggio nell'utilizzo della memoria, senza aggiungere molto codice extra! Nota che se non avessimo bisogno di accumulare risultati alla fine, ad esempio, se ogni elemento fosse salvato nel database e potesse essere poi dimenticato, ci sarebbe ancora meno utilizzo della memoria. Per fare un pigro enumerabile valutare alla fine della catena, basta aggiungere un'ultima chiamata a force .

Un'altra cosa da notare sull'esempio è che la catena inizia con una chiamata a times prima di lazy , che utilizza pochissima memoria poiché restituisce solo un enumeratore che genererà un numero intero ogni volta che viene invocato. Quindi, se è possibile utilizzare un enumerabile invece di un grande array all'inizio della catena, ciò sarà d'aiuto.

Tenere tutto in enormi array e mappe è conveniente, ma negli scenari del mondo reale raramente è necessario farlo.

Un'applicazione del mondo reale per la creazione di un enumerable da inserire pigramente in una sorta di pipeline di elaborazione è l'elaborazione di dati impaginati. Quindi, invece di richiedere tutte le pagine e metterle in un grande array, potrebbero essere esposte attraverso un enumeratore che nasconde bene tutti i dettagli di impaginazione. Questo potrebbe assomigliare a:

 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

Conclusione

Abbiamo effettuato alcune caratterizzazioni dell'utilizzo della memoria in Ruby e esaminato alcuni strumenti generali per rintracciare problemi di memoria, nonché alcuni casi comuni e modi per migliorarli. I casi comuni che abbiamo esplorato non sono affatto esaurienti e sono fortemente influenzati dal tipo di problemi che ho incontrato personalmente. Tuttavia, il più grande vantaggio potrebbe essere solo entrare nella mentalità di pensare a come il codice influirà sull'utilizzo della memoria.

Correlati: Concorrenza e parallelismo di Ruby: un tutorial pratico