Руководство по решению проблем с памятью в Ruby

в 9:00, , рубрики: memory management, performance optimization, profiling, ruby, ruby on rails, Блог компании Mail.Ru Group, Серверная оптимизация

Руководство по решению проблем с памятью в Ruby - 1

Наверняка есть везучие Ruby-разработчики, которые никогда не страдали от проблем с памятью. Но всем остальным приходится тратить невероятно много сил, чтобы разобраться, почему использование памяти вышло из-под контроля, и устранить причины. К счастью, если у вас достаточно современная версия Ruby (начиная с 2.1), то вам доступны замечательные инструменты и методики для решения распространённых проблем. Мне кажется, что оптимизация памяти может приносить радость и удовлетворение, но я могу быть одинок в своём мнении.

Как бывает со всеми формами оптимизации, возможно, это приведёт к усложнению кода. Так что не сто̒ит этим заниматься, если вы не получите измеряемые и значительные преимущества.

Все описанные ниже процедуры выполнены с помощью канонического MRI Ruby 2.2.4, но и другие версии 2.1+ должны работать аналогично.

Это не утечка памяти!

Когда возникает проблема с памятью, то сперва в голову приходит только одно — утечка. Например, вы можете увидеть в веб-приложении, что после запуска сервера повторяющиеся вызовы одной и той же функции с каждым разом потребляют всё больше памяти. Конечно, случаются и в самом деле утечки, но я готов спорить, что куда чаще встречаются проблемы, которые выглядят как утечки, но ими не являются.

Рассмотрим выдуманный пример: раз за разом будем создавать и сбрасывать большой массив хешей. Вот часть кода, которая будет использоваться в разных примерах в этой статье:

# 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, которые могли стать причиной этой проблемы с памятью.

Разделяй и властвуй

Итак, если нам нужно работать с большими объёмами данных, то мы обречены терять кучу оперативной памяти? К счастью, это не тот случай. Если взять предыдущий пример и уменьшить размер массива, то мы обнаружим, что потребление памяти выровняется раньше.

Руководство по решению проблем с памятью в Ruby - 2

Это означает, что если мы можем разбить нашу работу на более мелкие фрагменты и обработать, чтобы исключить одновременное использование большого количества объектов, то мы избежим сильного увеличения потребления памяти. К сожалению, для этого часто приходится взять чистый и красивый код и превратить его в более громоздкий, который делает всё то же самое, но эффективнее с точки зрения памяти.

Изолирование горячих точек потребления памяти

Часто в коде источник проблемы с памятью не столь очевиден, как в примере build_arrays.rb. Необходимо сначала изолировать причину и только затем приступать к её изучению, потому что легко можно сделать ошибочные выводы о причине проблемы.

Руководство по решению проблем с памятью в Ruby - 3

Обычно для выявления проблем с памятью я использую два подхода, зачастую комбинируя:

  • оставляю код без изменений и оборачиваю его в профилировщик;
  • отслеживаю использование памяти процессом, убирая/добавляя разные части кода, которые могут быть причиной проблемы.

Здесь в качестве профилировщика я воспользуюсь 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.

Выполним профилирование кода, чтобы понять, где осуществляются распределения памяти:

# profile.rb
require "memory_profiler"
require_relative "./people"

report = MemoryProfiler.report do
  run(1000)
end
report.pretty_print(to_file: "profile.txt")

Обратите внимание, что run здесь скармливается в десять раз меньшее количество, чем в предыдущем примере. Профилировщик сам по себе потребляет много памяти, и это может привести к её исчерпанию во время профилирования кода, который уже занял большой объём.

Файл получается довольно длинным, включает в себя распределение и удержание памяти и объектов в gem’е, файлы, а также уровни размещения. Настоящее обилие информации, в которой есть пара интересных частей:

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- или JSON-документы. Потоковые десериализаторы позволяют постепенно извлекать то, что вам нужно, сохраняя низкое потребление памяти.

Вот пример парсинга 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-строку. Хороший API для этого предоставляет gem json-write-stream, и тогда наш пример можно конвертировать:

# 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 появилась замечательная возможность делать ленивые перечислители (lazy enumerator). Это позволяет сильно уменьшить потребление памяти при вызове цепочки методов перечислителя. Начнём с неленивого кода:

# 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

Руководство по решению проблем с памятью в Ruby - 4

На каждой стадии цепочки выполняется итерация по всем элементам и создаётся новый массив, у которого есть вызываемый им следующий метод в цепочке, и т. д. Давайте посмотрим, что происходит, когда мы делаем это ленивым способом, просто добавляя вызов 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, а потом lazy, ведь первый потребляет очень мало памяти, всего лишь возвращая перечислитель, который генерирует целочисленное при каждом вызове. Так что если в начале цепочки вместо массива можно использовать перечислитель, то это также является плюсом, снижая потребление памяти.

Удобно держать всё в огромных массивах и коллекциях (map), но в реальных ситуациях это допускается только в редких случаях.

Создавать enumerable для ленивой обработки данных можно, например, при работе с данными, разделёнными на страницы. Вместо того чтобы запрашивать все страницы и класть их в один большой массив, можно выдавать их через перечислитель, который замечательно прячет все подробности разбиения на страницы. Например, так:

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, рассмотрели несколько основных инструментов для отслеживания проблем с памятью, разобрали частые ситуации и способы оптимизации. Рассмотренные ситуации не претендуют на полноту охвата и иллюстрируют самые разные проблемы, с которыми я лично сталкивался. Однако главный результат статьи — попытка задуматься о том, как код влияет на потребление памяти.

Автор: Mail.Ru Group

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js