Vânarea problemelor de memorie în Ruby: un ghid definitiv
Publicat: 2022-03-11Sunt sigur că există niște dezvoltatori Ruby norocoși care nu vor avea niciodată probleme cu memoria, dar pentru noi ceilalți, este incredibil de dificil să găsim acolo unde utilizarea memoriei scapă de sub control și să o remediem. Din fericire, dacă utilizați un Ruby modern (2.1+), există câteva instrumente și tehnici excelente disponibile pentru a rezolva problemele comune. S-ar putea spune, de asemenea, că optimizarea memoriei poate fi distractivă și plină de satisfacții, deși s-ar putea să fiu singur în acest sentiment.
Ca și în cazul tuturor formelor de optimizare, șansele sunt că va adăuga complexitate codului, așa că nu merită făcut decât dacă există câștiguri măsurabile și semnificative.
Tot ceea ce este descris aici se face folosind RMN-ul canonic Ruby, versiunea 2.2.4, deși alte versiuni 2.1+ ar trebui să se comporte similar.
Nu este o scurgere de memorie!
Când se descoperă o problemă de memorie, este ușor să ajungeți la concluzia că există o scurgere de memorie. De exemplu, într-o aplicație web, puteți vedea că, după ce vă porniți serverul, apelurile repetate către același punct final cresc consumul de memorie cu fiecare solicitare. Există cu siguranță cazuri în care au loc scurgeri legitime de memorie, dar aș pariez că sunt mult depășite numeric de problemele de memorie cu același aspect care nu sunt de fapt scurgeri.
Ca exemplu (conceput), să ne uităm la un pic de cod Ruby care construiește în mod repetat o serie mare de hashe-uri și îl aruncă. Mai întâi, iată un cod care va fi împărtășit în exemplele din această postare:
# 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
Și constructorul de matrice:
# 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
Bijuteria get_process_mem este doar o modalitate convenabilă de a obține memoria utilizată de procesul actual Ruby. Ceea ce vedem este același comportament descris mai sus, o creștere continuă a utilizării memoriei.
$ 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
Cu toate acestea, dacă rulăm mai multe iterații, vom ajunge în cele din urmă la plată.
$ 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
Lovirea acestui platou este semnul distinctiv de a nu fi o scurgere de memorie reală sau că scurgerea de memorie este atât de mică încât nu este vizibilă în comparație cu restul utilizării memoriei. Ceea ce poate să nu fie intuitiv este motivul pentru care utilizarea memoriei continuă să crească după prima iterație. La urma urmei, a construit o matrice mare, dar apoi a aruncat-o prompt și a început să construiască una nouă de aceeași dimensiune. Nu poate folosi doar spațiul eliberat de matricea anterioară? Răspunsul, care explică problema noastră, este nu. Pe lângă reglarea colectorului de gunoi, nu aveți control asupra momentului în care acesta rulează, iar ceea ce vedem în exemplul build_arrays.rb
este că noi alocări de memorie sunt făcute înainte de colectarea de gunoi a obiectelor noastre vechi, aruncate.
Ar trebui să subliniez că aceasta nu este un fel de problemă oribilă de gestionare a memoriei specifică lui Ruby, dar este în general aplicabilă limbilor colectate de gunoi. Doar pentru a mă asigura de acest lucru, am reprodus în esență același exemplu cu Go și am văzut rezultate similare. Cu toate acestea, există biblioteci Ruby care facilitează crearea acestui tip de problemă de memorie.
Diviza și cuceri
Deci, dacă trebuie să lucrăm cu cantități mari de date, suntem sortiți să aruncăm o mulțime de RAM în problema noastră? Din fericire, nu este cazul. Dacă luăm exemplul build_arrays.rb
și micșorăm dimensiunea matricei, vom vedea o scădere a punctului în care nivelul de utilizare a memoriei este aproximativ proporțională cu dimensiunea matricei.
Aceasta înseamnă că, dacă ne putem împărți munca în bucăți mai mici pentru a le procesa și a evita să existe prea multe obiecte simultan, putem reduce dramatic amprenta memoriei. Din păcate, asta înseamnă adesea să luați cod frumos și curat și să îl transformați în mai mult cod care face același lucru, doar într-un mod mai eficient din punct de vedere al memoriei.
Izolarea hotspot-urilor de utilizare a memoriei
Într-o bază de cod reală, sursa unei probleme de memorie probabil nu va fi la fel de evidentă ca în exemplul build_arrays.rb
. Izolarea unei probleme de memorie înainte de a încerca să o remediați și să o remediați este esențială, deoarece este ușor să faceți presupuneri incorecte cu privire la ceea ce cauzează problema.
În general, folosesc două abordări, adesea în combinație, pentru a urmări problemele de memorie: lăsând codul intact și împachetând un profiler în jurul acestuia și monitorizarea utilizării memoriei procesului în timp ce dezactivez/activez diferite părți ale codului pe care bănuiesc că ar putea fi problematică. Voi folosi aici memory_profiler pentru profilare, dar ruby-prof este o altă opțiune populară, iar derailed_benchmarks are câteva capabilități grozave specifice șinelor.
Iată un cod care va folosi o mulțime de memorie, unde este posibil să nu fie imediat clar care pas crește cel mai mult utilizarea memoriei:
# 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
Folosind get_process_mem, putem verifica rapid că folosește multă memorie atunci când sunt create o mulțime de înregistrări Person
.
# before_and_after.rb require_relative "./people" print_usage_before_and_after do run(ARGV.shift.to_i) end
Rezultat:
$ ruby before_and_after.rb 10000 Before - MEMORY USAGE(MB): 37 After - MEMORY USAGE(MB): 96
Privind prin cod, există mai mulți pași care par a fi candidați buni pentru utilizarea multă memorie: construirea unei game mari de șiruri, apelarea #to_a
pe o relație Active Record pentru a crea o gamă largă de obiecte Active Record (nu este o idee grozavă , dar făcut în scopuri demonstrative) și serializarea matricei de obiecte Active Record.
Putem apoi profila acest cod pentru a vedea unde au loc alocările de memorie:
# profile.rb require "memory_profiler" require_relative "./people" report = MemoryProfiler.report do run(1000) end report.pretty_print(to_file: "profile.txt")
Rețineți că numărul alimentat pentru a run
aici este 1/10 din exemplul anterior, deoarece profilerul în sine utilizează multă memorie și poate duce de fapt la epuizarea memoriei atunci când se profilează codul care provoacă deja o utilizare mare a memoriei.
Fișierul cu rezultate este destul de lung și include alocarea și păstrarea memoriei și a obiectelor la nivelurile de bijuterie, fișier și locație. Există o mulțime de informații de explorat, dar iată câteva fragmente interesante:
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
Vedem cele mai multe alocări care au loc în cadrul Active Record, ceea ce ar părea să indice fie instanțiarea tuturor obiectelor din matricea de records
, fie serializarea cu #to_json
. Apoi, ne putem testa utilizarea memoriei fără profiler în timp ce dezactivăm acești suspecți. Nu putem dezactiva recuperarea records
și totuși putem face pasul de serializare, așa că să încercăm mai întâi să dezactivăm serializarea.
# File.open("people.txt", "w") { |out| out << records.to_json }
Rezultat:

$ ruby before_and_after.rb 10000 Before: 36 MB After: 47 MB
Într-adevăr, acesta pare să fie locul în care se îndreaptă cea mai mare parte a memoriei, cu delta memoriei înainte/după scăzând cu 81% dacă o săriți peste. De asemenea, putem vedea ce se întâmplă dacă încetăm să forțăm crearea unei game mari de înregistrări.
# records = Person.all.to_a records = Person.all # File.open("people.txt", "w") { |out| out << records.to_json }
Rezultat:
$ ruby before_and_after.rb 10000 Before: 36 MB After: 40 MB
Acest lucru reduce și utilizarea memoriei, deși este o reducere cu un ordin de mărime mai mică decât dezactivarea serializării. Deci, în acest moment, cunoaștem cei mai mari vinovați ai noștri și putem lua o decizie cu privire la ce să optimizăm pe baza acestor date.
Deși exemplul de aici a fost conceput, abordările sunt aplicabile în general. Este posibil ca rezultatele aplicației de profil să nu vă îndrepte spre locul exact din codul dvs. în care se află problema și, de asemenea, pot fi interpretate greșit, așa că este o idee bună să urmăriți uitându-vă la utilizarea reală a memoriei în timp ce activați și dezactivați secțiuni de cod. În continuare, vom analiza câteva cazuri comune în care utilizarea memoriei devine o problemă și cum să le optimizăm.
Deserializarea
O sursă comună de probleme de memorie este deserializarea unor cantități mari de date din XML, JSON sau alt format de serializare a datelor. Utilizarea metodelor precum JSON.parse
sau Hash.from_xml
de la Active Support este incredibil de convenabilă, dar atunci când datele pe care le încărcați sunt mari, structura de date rezultată care este încărcată în memorie poate fi enormă.
Dacă aveți control asupra sursei datelor, puteți face lucruri pentru a limita cantitatea de date pe care o primiți, cum ar fi adăugarea de filtrare sau suport pentru paginare. Dar dacă este o sursă externă sau una pe care nu o poți controla, o altă opțiune este să folosești un deserializator de streaming. Pentru XML, Ox este o opțiune, iar pentru JSON yajl-ruby pare să funcționeze în mod similar, deși nu am prea multă experiență cu el.
Iată un exemplu de analiză a unui fișier XML de 1,7 MB, folosind 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 pentru un fișier de 1,7 MB! În mod clar, acest lucru nu se va extinde bine. Iată versiunea parserului de 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
Acest lucru ne duce la o creștere neglijabilă a memoriei și ar trebui să putem gestiona fișiere mult mai mari. Cu toate acestea, compromisul este că acum avem 28 de linii de cod de gestionare de care nu aveam nevoie înainte, ceea ce pare că ar fi predispus la erori, iar pentru utilizare în producție ar trebui să aibă câteva teste în jurul acestuia.
Serializare
După cum am văzut în secțiunea despre izolarea hotspot-urilor de utilizare a memoriei, serializarea poate avea costuri mari de memorie. Iată partea cheie a people.rb
de mai devreme.
# to_json.rb require_relative "./common" print_usage_before_and_after do File.open("people.txt", "w") { |out| out << Person.all.to_json } end
Rulând acest lucru cu 100.000 de înregistrări în baza de date, obținem:
$ ruby to_json.rb Before: 36 MB After: 505 MB
Problema cu apelarea #to_json
aici este că instanțiază un obiect pentru fiecare înregistrare și apoi codifică în JSON. Generarea înregistrare-cu-înregistrare JSON, astfel încât să fie necesar să existe un singur obiect de înregistrare la un moment dat, reduce utilizarea memoriei în mod semnificativ. Niciuna dintre bibliotecile populare Ruby JSON nu pare să se ocupe de acest lucru, dar o abordare recomandată în mod obișnuit este construirea manuală a șirului JSON. Există o bijuterie json-write-stream care oferă un API frumos pentru a face acest lucru, iar convertirea exemplului nostru în acesta arată astfel:
# 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
Încă o dată, vedem că optimizarea ne-a oferit mai mult cod, dar rezultatul pare că merită:
$ ruby json_stream.rb Before: 36 MB After: 56 MB
Fiind Leneș
O caracteristică excelentă adăugată la Ruby începând cu 2.0 este capacitatea de a face enumeratorii leneși. Acest lucru este excelent pentru îmbunătățirea utilizării memoriei la înlănțuirea metodelor pe un enumerator. Să începem cu un cod care nu este leneș:
# 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
Rezultat:
$ ruby not_lazy.rb 1_000_000 Before: 36 MB After: 546 MB
Ceea ce se întâmplă aici este că la fiecare pas din lanț, acesta iterează peste fiecare element din enumerator, producând o matrice care are metoda ulterioară din lanț invocată pe el și așa mai departe. Să vedem ce se întâmplă când facem acest lucru leneș, care necesită doar adăugarea unui apel la lazy
pe enumeratorul pe care îl primim de la 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
Rezultat:
$ ruby lazy.rb 1_000_000 Before: 36 MB After: 52 MB
În sfârșit, un exemplu care ne oferă un câștig uriaș de utilizare a memoriei, fără a adăuga o mulțime de cod suplimentar! Rețineți că, dacă nu ar fi nevoie să acumulăm niciun rezultat la sfârșit, de exemplu, dacă fiecare articol a fost salvat în baza de date și ar putea fi apoi uitat, ar fi și mai puțină utilizare a memoriei. Pentru a face o evaluare enumerabilă leneșă la sfârșitul lanțului, trebuie doar să adăugați un apel final la force
.
Un alt lucru de remarcat despre exemplu este că lanțul începe cu un apel la times
înainte de lazy
, care utilizează foarte puțină memorie, deoarece returnează doar un enumerator care va genera un număr întreg de fiecare dată când este invocat. Deci, dacă un enumerabil poate fi folosit în loc de o matrice mare la începutul lanțului, asta va ajuta.
O aplicație reală de construire a unui enumerabil pentru a alimenta leneș într-un fel de conductă de procesare este procesarea datelor paginate. Deci, în loc să solicite toate paginile și să le pună într-o matrice mare, acestea ar putea fi expuse printr-un enumerator care ascunde frumos toate detaliile de paginare. Acest lucru ar putea arăta ceva de genul:
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
Concluzie
Am făcut o caracterizare a utilizării memoriei în Ruby și am analizat câteva instrumente generale pentru urmărirea problemelor de memorie, precum și câteva cazuri comune și modalități de a le îmbunătăți. Cazurile obișnuite pe care le-am explorat nu sunt deloc cuprinzătoare și sunt foarte părtinitoare de tipul de probleme pe care le-am întâlnit personal. Cu toate acestea, cel mai mare câștig ar putea fi doar să vă gândiți la modul în care codul va afecta utilizarea memoriei.