Поиск проблем с памятью в Ruby: подробное руководство
Опубликовано: 2022-03-11Я уверен, что есть некоторые удачливые разработчики Ruby, которые никогда не столкнутся с проблемами с памятью, но для остальных из нас невероятно сложно выследить, где использование памяти выходит из-под контроля, и исправить это. К счастью, если вы используете современный Ruby (2.1+), есть несколько отличных инструментов и методов для решения распространенных проблем. Можно также сказать, что оптимизация памяти может быть интересной и полезной, хотя я одинок в этом мнении.
Как и в случае со всеми формами оптимизации, есть вероятность, что это добавит сложности коду, поэтому его не стоит делать, если нет измеримых и значительных выгод.
Все описанное здесь сделано с использованием канонического MRI Ruby версии 2.2.4, хотя другие версии 2.1+ должны вести себя аналогично.
Это не утечка памяти!
Когда обнаруживается проблема с памятью, легко сделать вывод об утечке памяти. Например, в веб-приложении вы можете увидеть, что после запуска сервера повторные вызовы одной и той же конечной точки увеличивают использование памяти с каждым запросом. Конечно, есть случаи, когда происходят законные утечки памяти, но я готов поспорить, что их значительно меньше, чем проблем с памятью с таким же внешним видом, которые на самом деле не являются утечками.
В качестве (надуманного) примера давайте рассмотрим фрагмент кода Ruby, который постоянно создает большой массив хэшей и отбрасывает его. Во-первых, вот некоторый код, который будет использоваться в примерах в этом посте:
# 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
И построитель массива:
# 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
Гем get_process_mem — это просто удобный способ получить память, используемую текущим процессом Ruby. То, что мы видим, это то же самое поведение, которое было описано выше, постоянное увеличение использования памяти.
$ 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
Однако, если мы проведем больше итераций, мы в конечном итоге выйдем на плато.
$ 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
Достижение этого плато является признаком отсутствия фактической утечки памяти или того, что утечка памяти настолько мала, что не видна по сравнению с остальным использованием памяти. Что может быть не интуитивно понятным, так это то, почему использование памяти продолжает расти после первой итерации. В конце концов, он построил большой массив, но тут же от него отказался и начал строить новый того же размера. Разве он не может просто использовать пространство, освобожденное предыдущим массивом? Ответ, объясняющий нашу проблему, — нет. Помимо настройки сборщика мусора, вы не можете контролировать, когда он запускается, и то, что мы видим в примере build_arrays.rb
, — это новое выделение памяти, выполняемое до сборки мусора наших старых, отброшенных объектов.
Я должен отметить, что это не какая-то ужасная проблема управления памятью, специфичная для Ruby, но обычно применимая к языкам со сборкой мусора. Просто чтобы убедиться в этом, я воспроизвел практически тот же пример с Go и увидел похожие результаты. Однако существуют библиотеки Ruby, которые упрощают создание такой проблемы с памятью.
Разделяй и властвуй
Итак, если нам нужно работать с большими блоками данных, обречены ли мы просто тратить много оперативной памяти на решение нашей проблемы? К счастью, это не так. Если мы возьмем пример build_arrays.rb
и уменьшим размер массива, мы увидим уменьшение точки плато использования памяти, примерно пропорциональное размеру массива.
Это означает, что если мы сможем разбить нашу работу на более мелкие части для обработки и избежать одновременного существования слишком большого количества объектов, мы сможем значительно сократить объем памяти. К сожалению, это часто означает, что нужно взять хороший, чистый код и превратить его в дополнительный код, который делает то же самое, но более эффективно использует память.
Изоляция горячих точек использования памяти
В реальной кодовой базе источник проблемы с памятью, скорее всего, будет не столь очевиден, как в примере с build_arrays.rb
. Изолировать проблему с памятью, прежде чем пытаться ее покопаться и исправить, очень важно, потому что легко сделать неверные предположения о том, что вызывает проблему.
Обычно я использую два подхода, часто в комбинации, для отслеживания проблем с памятью: оставляю код нетронутым и обертываю его профилировщиком, а также отслеживаю использование памяти процессом, отключая/включая различные части кода, которые, как я подозреваю, могут быть проблематичными. Здесь я буду использовать memory_profiler для профилирования, но еще одним популярным вариантом является ruby-prof, а derailed_benchmarks обладает рядом замечательных возможностей, специфичных для Rails.
Вот некоторый код, который будет использовать кучу памяти, где может быть не сразу ясно, какой шаг увеличивает использование памяти больше всего:
# 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
Используя get_process_mem, мы можем быстро убедиться, что он использует много памяти, когда создается много записей Person
.
# before_and_after.rb require_relative "./people" print_usage_before_and_after do run(ARGV.shift.to_i) end
Результат:
$ ruby before_and_after.rb 10000 Before - MEMORY USAGE(MB): 37 After - MEMORY USAGE(MB): 96
Просматривая код, можно увидеть несколько шагов, которые кажутся хорошими кандидатами на использование большого количества памяти: построение большого массива строк, вызов #to_a
для отношения Active Record для создания большого массива объектов Active Record (не лучшая идея , но сделано в демонстрационных целях) и сериализация массива объектов Active Record.
Затем мы можем профилировать этот код, чтобы увидеть, где происходит выделение памяти:
# profile.rb require "memory_profiler" require_relative "./people" report = MemoryProfiler.report do run(1000) end report.pretty_print(to_file: "profile.txt")
Обратите внимание, что число, переданное для run
здесь, составляет 1/10 от предыдущего примера, поскольку сам профилировщик использует много памяти и фактически может привести к исчерпанию памяти при профилировании кода, который уже вызывает высокое использование памяти.
Файл результатов довольно длинный и включает в себя выделение памяти и объектов, а также их сохранение на уровне гемов, файлов и местоположений. Существует множество информации для изучения, но вот несколько интересных фрагментов:
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
Мы видим, что большинство аллокаций происходит внутри Active Record, что, по-видимому, указывает либо на создание экземпляров всех объектов в массиве records
, либо на сериализацию с помощью #to_json
. Затем мы можем проверить использование памяти без профилировщика, отключив эти подозреваемые. Мы не можем отключить получение records
и по-прежнему иметь возможность выполнять этап сериализации, поэтому давайте сначала попробуем отключить сериализацию.
# File.open("people.txt", "w") { |out| out << records.to_json }
Результат:

$ ruby before_and_after.rb 10000 Before: 36 MB After: 47 MB
Кажется, именно на это уходит большая часть памяти: дельта памяти до/после падает на 81% из-за ее пропуска. Мы также можем увидеть, что произойдет, если мы перестанем принудительно создавать большой массив записей.
# records = Person.all.to_a records = Person.all # File.open("people.txt", "w") { |out| out << records.to_json }
Результат:
$ ruby before_and_after.rb 10000 Before: 36 MB After: 40 MB
Это также снижает использование памяти, хотя и на порядок меньше, чем отключение сериализации. Итак, на данный момент мы знаем наших главных виновников и можем принять решение о том, что оптимизировать на основе этих данных.
Хотя пример здесь был надуманным, подходы в целом применимы. Результаты профилировщика могут не указать точное место в вашем коде, где находится проблема, а также могут быть неверно истолкованы, поэтому рекомендуется отслеживать фактическое использование памяти при включении и выключении разделов кода. Далее мы рассмотрим некоторые распространенные случаи, когда использование памяти становится проблемой, и способы их оптимизации.
Десериализация
Распространенным источником проблем с памятью является десериализация больших объемов данных из XML, JSON или другого формата сериализации данных. Использование таких методов, как JSON.parse
или Hash.from_xml
от Active Support, невероятно удобно, но когда данные, которые вы загружаете, велики, результирующая структура данных, загружаемая в память, может быть огромной.
Если у вас есть контроль над источником данных, вы можете ограничить объем получаемых данных, например добавить поддержку фильтрации или разбиения на страницы. Но если это внешний источник или тот, которым вы не можете управлять, другой вариант — использовать потоковый десериализатор. Для XML одним из вариантов является Ox, а для JSON похоже работает yajl-ruby, хотя у меня нет большого опыта работы с ним.
Вот пример разбора XML-файла размером 1,7 МБ с использованием 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 МБ для файла размером 1,7 МБ! Это явно не будет хорошо масштабироваться. Вот версия потокового парсера.
# 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
Это приводит нас к незначительному увеличению памяти и должно быть в состоянии обрабатывать файлы значительно большего размера. Однако компромисс заключается в том, что теперь у нас есть 28 строк кода обработчика, в которых мы не нуждались раньше, что, похоже, будет подвержено ошибкам, а для производственного использования необходимо провести некоторые тесты вокруг него.
Сериализация
Как мы видели в разделе об изоляции горячих точек использования памяти, сериализация может иметь большие затраты памяти. Вот ключевая часть people.rb
.
# to_json.rb require_relative "./common" print_usage_before_and_after do File.open("people.txt", "w") { |out| out << Person.all.to_json } end
Запустив это со 100 000 записей в базе данных, мы получим:
$ ruby to_json.rb Before: 36 MB After: 505 MB
Проблема с вызовом #to_json
здесь заключается в том, что он создает экземпляр объекта для каждой записи, а затем кодирует в JSON. Генерация JSON запись за записью, так что только один объект записи должен существовать одновременно, значительно снижает использование памяти. Похоже, что ни одна из популярных библиотек Ruby JSON не справляется с этим, но обычно рекомендуется создавать строку JSON вручную. Существует гем json-write-stream, который предоставляет хороший API для этого, и преобразование нашего примера в это выглядит так:
# 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
И снова мы видим, что оптимизация дала нам больше кода, но результат того стоит:
$ ruby json_stream.rb Before: 36 MB After: 56 MB
Лениться
Отличная функция, добавленная в Ruby, начиная с версии 2.0, — это возможность сделать счетчики ленивыми. Это отлично подходит для улучшения использования памяти при цепочке методов в перечислителе. Давайте начнем с некоторого кода, который не ленив:
# 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
Результат:
$ ruby not_lazy.rb 1_000_000 Before: 36 MB After: 546 MB
Здесь происходит то, что на каждом шаге в цепочке он перебирает каждый элемент в перечислителе, создавая массив, для которого вызывается последующий метод в цепочке, и так далее. Давайте посмотрим, что произойдет, когда мы сделаем это отложенным, для чего просто нужно добавить вызов lazy
перечислителя, который мы получаем от 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
Результат:
$ ruby lazy.rb 1_000_000 Before: 36 MB After: 52 MB
Наконец, пример, который дает нам огромный выигрыш в использовании памяти без добавления большого количества дополнительного кода! Обратите внимание, что если бы нам не нужно было накапливать какие-либо результаты в конце, например, если бы каждый элемент был сохранен в базе данных, а затем мог быть забыт, использование памяти было бы еще меньше. Чтобы сделать ленивую перечисляемую оценку в конце цепочки, просто добавьте последний вызов force
.
Еще одна вещь, которую следует отметить в этом примере, это то, что цепочка начинается с вызова times
before lazy
, который использует очень мало памяти, поскольку просто возвращает перечислитель, который будет генерировать целое число при каждом вызове. Поэтому, если вместо большого массива в начале цепочки можно использовать перечисляемое, это поможет.
Одним из реальных приложений построения перечислимого для ленивой подачи в какой-то конвейер обработки является обработка разбитых на страницы данных. Таким образом, вместо того, чтобы запрашивать все страницы и помещать их в один большой массив, они могут быть представлены через перечислитель, который хорошо скрывает все детали разбиения на страницы. Это может выглядеть примерно так:
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
Заключение
Мы сделали некоторую характеристику использования памяти в Ruby и рассмотрели некоторые общие инструменты для отслеживания проблем с памятью, а также некоторые распространенные случаи и способы их улучшения. Распространенные случаи, которые мы исследовали, ни в коем случае не являются исчерпывающими и сильно предвзятыми из-за тех проблем, с которыми я лично столкнулся. Тем не менее, самый большой выигрыш может заключаться в том, чтобы просто подумать о том, как код повлияет на использование памяти.