Polowanie na problemy z pamięcią w Ruby: ostateczny przewodnik
Opublikowany: 2022-03-11Jestem pewien, że jest kilku szczęśliwych programistów Ruby, którzy nigdy nie napotkają problemów z pamięcią, ale dla reszty z nas niezwykle trudno jest znaleźć miejsce, w którym zużycie pamięci wymyka się spod kontroli i naprawić. Na szczęście, jeśli używasz nowoczesnego Rubiego (2.1+), istnieje kilka świetnych narzędzi i technik do radzenia sobie z typowymi problemami. Można również powiedzieć, że optymalizacja pamięci może być zabawna i satysfakcjonująca, chociaż mogę być w tym odosobnieniu sama.
Podobnie jak w przypadku wszystkich form optymalizacji, istnieje prawdopodobieństwo, że zwiększy to złożoność kodu, więc nie warto tego robić, chyba że istnieją wymierne i znaczące korzyści.
Wszystko opisane tutaj jest wykonane przy użyciu kanonicznego MRI Ruby w wersji 2.2.4, chociaż inne wersje 2.1+ powinny zachowywać się podobnie.
To nie wyciek pamięci!
Gdy zostanie wykryty problem z pamięcią, łatwo można szybko dojść do wniosku, że występuje przeciek pamięci. Na przykład w aplikacji internetowej możesz zauważyć, że po uruchomieniu serwera powtarzające się wywołania tego samego punktu końcowego zwiększają zużycie pamięci z każdym żądaniem. Z pewnością istnieją przypadki, w których zdarzają się uzasadnione wycieki pamięci, ale założę się, że problemy z pamięcią o tym samym wyglądzie są znacznie przewyższające ich liczbę, które w rzeczywistości nie są wyciekami.
Jako (wymyślony) przykład spójrzmy na fragment kodu Rubiego, który wielokrotnie buduje dużą tablicę skrótów i odrzuca ją. Po pierwsze, oto trochę kodu, który zostanie udostępniony w przykładach w tym poście:
# 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
A konstruktor tablic:
# 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
Gem get_process_mem jest po prostu wygodnym sposobem na uzyskanie pamięci używanej przez bieżący proces Rubiego. Widzimy to samo zachowanie, które zostało opisane powyżej, ciągły wzrost zużycia pamięci.
$ 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
Jeśli jednak wykonamy więcej iteracji, w końcu osiągniemy 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
Uderzenie w ten plateau jest znakiem rozpoznawczym tego, że nie jest to rzeczywisty wyciek pamięci lub że wyciek pamięci jest tak mały, że nie jest widoczny w porównaniu z resztą użycia pamięci. To, co może nie być intuicyjne, to fakt, że zużycie pamięci nadal rośnie po pierwszej iteracji. W końcu zbudował dużą macierz, ale natychmiast ją odrzucił i zaczął budować nową o tym samym rozmiarze. Czy nie może po prostu wykorzystać miejsca zwolnionego przez poprzednią tablicę? Odpowiedź, która wyjaśnia nasz problem, brzmi: nie. Oprócz dostrajania garbage collectora, nie masz kontroli nad tym, kiedy działa, a to, co widzimy w przykładzie build_arrays.rb
, to nowe alokacje pamięci dokonywane przed wyrzucaniem elementów bezużytecznych naszych starych, odrzuconych obiektów.
Powinienem zaznaczyć, że nie jest to jakiś okropny problem z zarządzaniem pamięcią specyficzny dla Rubiego, ale ogólnie dotyczy języków gromadzonych śmieci. Aby się upewnić, powtórzyłem zasadniczo ten sam przykład z Go i zobaczyłem podobne wyniki. Istnieją jednak biblioteki Ruby, które ułatwiają tworzenie tego rodzaju problemów z pamięcią.
Dziel i rządź
Więc jeśli musimy pracować z dużymi porcjami danych, czy jesteśmy skazani na to, aby po prostu rzucić dużo pamięci RAM na nasz problem? Na szczęście tak nie jest. Jeśli weźmiemy przykład build_arrays.rb
i zmniejszymy rozmiar tablicy, zobaczymy zmniejszenie punktu, w którym zużycie pamięci ulega plateau, które jest mniej więcej proporcjonalne do rozmiaru tablicy.
Oznacza to, że jeśli możemy podzielić naszą pracę na mniejsze części do przetworzenia i uniknąć jednoczesnego istnienia zbyt wielu obiektów, możemy radykalnie zmniejszyć zużycie pamięci. Niestety, często oznacza to wzięcie ładnego, czystego kodu i przekształcenie go w więcej kodu, który robi to samo, tylko w sposób bardziej wydajny pod względem pamięci.
Izolowanie hotspotów wykorzystania pamięci
W prawdziwej bazie kodu źródło problemu z pamięcią prawdopodobnie nie będzie tak oczywiste, jak w przykładzie build_arrays.rb
. Wyizolowanie problemu z pamięcią przed próbą zagłębienia się w niego i naprawienia go jest niezbędne, ponieważ łatwo jest poczynić nieprawidłowe założenia dotyczące przyczyny problemu.
Zwykle używam dwóch podejść, często w połączeniu, do śledzenia problemów z pamięcią: pozostawianie kodu w stanie nienaruszonym i otaczanie go profilerem oraz monitorowanie użycia pamięci przez proces przy jednoczesnym wyłączaniu/włączaniu różnych części kodu, które, jak podejrzewam, mogą być problematyczne. Będę tutaj używał memory_profiler do profilowania, ale ruby-prof to kolejna popularna opcja, a derailed_benchmarks ma kilka świetnych możliwości specyficznych dla Rails.
Oto kod, który będzie używał dużej ilości pamięci, gdzie może nie być od razu jasne, który krok najbardziej zwiększa użycie pamięci:
# 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
Używając get_process_mem, możemy szybko sprawdzić, czy używa dużo pamięci, gdy tworzonych jest wiele rekordów Person
.
# before_and_after.rb require_relative "./people" print_usage_before_and_after do run(ARGV.shift.to_i) end
Wynik:
$ ruby before_and_after.rb 10000 Before - MEMORY USAGE(MB): 37 After - MEMORY USAGE(MB): 96
Przeglądając kod, jest wiele kroków, które wydają się dobrymi kandydatami do użycia dużej ilości pamięci: budowanie dużej tablicy ciągów, wywoływanie #to_a
w relacji Active Record w celu utworzenia dużej tablicy obiektów Active Record (nie jest to świetny pomysł , ale zrobione w celach demonstracyjnych) i serializacji tablicy obiektów Active Record.
Następnie możemy sprofilować ten kod, aby zobaczyć, gdzie dzieje się alokacja pamięci:
# profile.rb require "memory_profiler" require_relative "./people" report = MemoryProfiler.report do run(1000) end report.pretty_print(to_file: "profile.txt")
Należy zauważyć, że liczba, która ma zostać przekazana do run
, to 1/10 poprzedniego przykładu, ponieważ sam profiler zużywa dużo pamięci i może w rzeczywistości prowadzić do wyczerpania pamięci podczas profilowania kodu, który już powoduje wysokie użycie pamięci.
Plik wyników jest dość długi i zawiera alokację i przechowywanie pamięci i obiektów na poziomie klejnotu, pliku i lokalizacji. Jest mnóstwo informacji do zbadania, ale oto kilka interesujących fragmentów:
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
Widzimy, że najwięcej alokacji dzieje się w Active Record, co wydaje się wskazywać na utworzenie wystąpienia wszystkich obiektów w tablicy records
lub serializację za pomocą #to_json
. Następnie możemy przetestować nasze użycie pamięci bez profilera, jednocześnie wyłączając tych podejrzanych. Nie możemy wyłączyć pobierania records
i nadal możemy wykonać krok serializacji, więc najpierw spróbujmy wyłączyć serializację.
# File.open("people.txt", "w") { |out| out << records.to_json }
Wynik:

$ ruby before_and_after.rb 10000 Before: 36 MB After: 47 MB
Rzeczywiście wydaje się, że jest to miejsce, w którym znajduje się większość pamięci, przy czym delta pamięci przed/po spada o 81% przez pominięcie jej. Możemy również zobaczyć, co się stanie, jeśli przestaniemy wymuszać tworzenie dużej liczby rekordów.
# records = Person.all.to_a records = Person.all # File.open("people.txt", "w") { |out| out << records.to_json }
Wynik:
$ ruby before_and_after.rb 10000 Before: 36 MB After: 40 MB
Zmniejsza to również użycie pamięci, chociaż jest to o rząd wielkości mniejsza redukcja niż wyłączenie serializacji. W tym momencie znamy naszych największych winowajców i na podstawie tych danych możemy podjąć decyzję, co zoptymalizować.
Chociaż przykład tutaj został wymyślony, podejścia mają ogólne zastosowanie. Wyniki profilera mogą nie wskazywać dokładnie miejsca w kodzie, w którym leży problem, a także mogą zostać błędnie zinterpretowane, dlatego dobrym pomysłem jest sprawdzenie rzeczywistego użycia pamięci podczas włączania i wyłączania sekcji kodu. Następnie przyjrzymy się kilku typowym przypadkom, w których zużycie pamięci staje się problemem i jak je zoptymalizować.
Deserializacja
Typowym źródłem problemów z pamięcią jest deserializacja dużych ilości danych z XML, JSON lub innego formatu serializacji danych. Korzystanie z metod takich jak JSON.parse
lub Hash.from_xml
usługi Active Support jest niezwykle wygodne, ale gdy ładowane dane są duże, wynikowa struktura danych ładowana do pamięci może być ogromna.
Jeśli masz kontrolę nad źródłem danych, możesz ograniczyć ilość otrzymywanych danych, na przykład dodać obsługę filtrowania lub paginacji. Ale jeśli jest to źródło zewnętrzne lub takie, którego nie możesz kontrolować, inną opcją jest użycie deserializatora strumieniowego. W przypadku XML Ox jest jedną z opcji, a dla JSON yajl-ruby wydaje się działać podobnie, chociaż nie mam z tym dużego doświadczenia.
Oto przykład parsowania pliku XML o wielkości 1,7 MB przy użyciu 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 na plik 1,7 MB! To wyraźnie nie będzie się dobrze skalować. Oto wersja parsera strumieniowego.
# 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
To sprowadza nas do znikomego wzrostu pamięci i powinno być w stanie obsłużyć znacznie większe pliki. Jednak kompromis polega na tym, że mamy teraz 28 wierszy kodu obsługi, których wcześniej nie potrzebowaliśmy, co wydaje się być podatne na błędy, a do użytku produkcyjnego powinno mieć kilka testów.
Serializacja
Jak widzieliśmy w sekcji dotyczącej izolowania hotspotów użycia pamięci, serializacja może wiązać się z wysokimi kosztami pamięci. Oto kluczowa część people.rb
z wcześniejszej wersji.
# to_json.rb require_relative "./common" print_usage_before_and_after do File.open("people.txt", "w") { |out| out << Person.all.to_json } end
Uruchamiając to ze 100 000 rekordów w bazie danych, otrzymujemy:
$ ruby to_json.rb Before: 36 MB After: 505 MB
Problem z wywołaniem #to_json
polega na tym, że tworzy on wystąpienie obiektu dla każdego rekordu, a następnie koduje go w formacie JSON. Generowanie rekordu JSON rekord po rekordzie, tak aby w danym momencie musiał istnieć tylko jeden obiekt rekordu, znacznie zmniejsza użycie pamięci. Wydaje się, że żadna z popularnych bibliotek Ruby JSON nie obsługuje tego, ale powszechnie zalecanym podejściem jest ręczne budowanie ciągu JSON. Istnieje gem json-write-stream, który zapewnia ładne API do tego, a konwersja naszego przykładu do tego wygląda tak:
# 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
Po raz kolejny widzimy, że optymalizacja dała nam więcej kodu, ale wynik wydaje się tego wart:
$ ruby json_stream.rb Before: 36 MB After: 56 MB
Być leniwym
Świetną funkcją dodaną do Rubiego, począwszy od wersji 2.0, jest możliwość rozleniwiania enumeratorów. Jest to doskonałe do poprawy wykorzystania pamięci podczas łączenia metod w module wyliczającym. Zacznijmy od kodu, który nie jest leniwy:
# 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
Wynik:
$ ruby not_lazy.rb 1_000_000 Before: 36 MB After: 546 MB
To, co się tutaj dzieje, polega na tym, że na każdym etapie łańcucha iteruje on po każdym elemencie w enumeratorze, tworząc tablicę, na której wywoływana jest kolejna metoda w łańcuchu i tak dalej. Zobaczmy, co się stanie, gdy zrobimy to leniwe, co wymaga jedynie dodania wywołania do lazy
na enumeratorze, który otrzymujemy od 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
Wynik:
$ ruby lazy.rb 1_000_000 Before: 36 MB After: 52 MB
Wreszcie przykład, który daje nam ogromną wygraną w zużyciu pamięci, bez dodawania dużej ilości dodatkowego kodu! Zwróć uwagę, że gdybyśmy nie musieli gromadzić żadnych wyników na końcu, na przykład, gdyby każdy element został zapisany w bazie danych i mógł zostać później zapomniany, zużycie pamięci byłoby jeszcze mniejsze. Aby wykonać leniwą przeliczalną ocenę na końcu łańcucha, po prostu dodaj ostatnie wywołanie force
.
Inną rzeczą, na którą należy zwrócić uwagę w tym przykładzie, jest to, że łańcuch zaczyna się od wywołania times
przed lazy
, który zużywa bardzo mało pamięci, ponieważ po prostu zwraca moduł wyliczający, który generuje liczbę całkowitą za każdym razem, gdy zostanie wywołany. Więc jeśli można użyć przeliczalnego zamiast dużej tablicy na początku łańcucha, to pomoże.
Jednym z zastosowań w świecie rzeczywistym, polegającym na budowaniu elementów przeliczalnych, które leniwie wprowadzają do pewnego rodzaju potoku przetwarzania, jest przetwarzanie danych podzielonych na strony. Więc zamiast prosić o wszystkie strony i umieszczać je w jednej dużej tablicy, można je ujawnić za pomocą enumeratora, który ładnie ukrywa wszystkie szczegóły paginacji. To może wyglądać mniej więcej tak:
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
Wniosek
Zrobiliśmy trochę charakterystyki użycia pamięci w Ruby i przyjrzeliśmy się kilku ogólnym narzędziom do śledzenia problemów z pamięcią, a także niektórym typowym przypadkom i sposobom ich poprawy. Typowe przypadki, które zbadaliśmy, w żadnym wypadku nie są wyczerpujące i są wysoce stronnicze ze względu na rodzaj problemów, z którymi osobiście się spotkałem. Jednak największym zyskiem może być po prostu myślenie o tym, jak kod wpłynie na zużycie pamięci.