Глубокое погружение в утечки памяти в Ruby

В первой части этой серии статей об утечках памяти мы рассмотрели, как Ruby управляет памятью и как работает сборка мусора (GC).

Возможно, вы можете позволить себе мощные машины с большим объемом памяти, и ваше приложение может перезапускаться достаточно часто, чтобы пользователи не замечали этого, но использование памяти имеет значение.

Выделение и сборка мусора не бесплатны. Если у вас есть утечка, вы тратите все больше и больше времени на сборку мусора вместо того, чтобы заниматься тем, для чего вы создавали свое приложение.

В этом посте мы подробнее рассмотрим инструменты, которые можно использовать для обнаружения и диагностики утечки памяти.

Давайте продолжим!

Поиск утечек в Ruby

Обнаружить утечку достаточно просто. Вы можете использовать GC, ObjectSpace и RSS-графики в вашем APM-инструменте, чтобы наблюдать за ростом использования памяти. Но просто знать, что у вас есть утечка, недостаточно для ее устранения. Необходимо знать, откуда она происходит. Сырые цифры не могут сказать вам этого.

К счастью, в экосистеме Ruby есть несколько отличных инструментов, позволяющих добавить контекст к этим цифрам. Два из них — memory-profiler и derailed_benchmarks.

memory_profiler в Ruby

Гем memory_profiler предлагает очень простой API и подробный (хотя и немного перегруженный) отчет о выделенной и сохраненной памяти — который включает классы объектов, которые были выделены, их размер, и где они были выделены. Его легко добавить в нашу «дырявую» программу.

# leaky.rb
require "memory_profiler"

an_array = []

report = MemoryProfiler.report do
  11.times do

    1000.times { an_array << "A" + "B" + "C" }
    puts "Array is #{an_array.size} items long"
  end

  GC.start
end

report.pretty_print
Вход в полноэкранный режим Выход из полноэкранного режима

Вывод отчета, который выглядит примерно так.

Total allocated: 440072 bytes (11001 objects)
Total retained:  440072 bytes (11001 objects)

allocated memory by gem
-----------------------------------
    440072  other

allocated memory by file
-----------------------------------
    440072  ./leaky.rb

allocated memory by location
-----------------------------------
    440000  ./leaky.rb:9
        72  ./leaky.rb:10

allocated memory by class
-----------------------------------
    440000  String
        72  Thread::Mutex

allocated objects by gem
-----------------------------------
     11001  other

allocated objects by file
-----------------------------------
     11001  ./leaky.rb

allocated objects by location
-----------------------------------
     11000  ./leaky.rb:9
         1  ./leaky.rb:10

allocated objects by class
-----------------------------------
     11000  String
         1  Thread::Mutex

retained memory by gem
-----------------------------------
    440072  other

retained memory by file
-----------------------------------
    440072  ./leaky.rb

retained memory by location
-----------------------------------
    440000  ./leaky.rb:9
        72  ./leaky.rb:10

retained memory by class
-----------------------------------
    440000  String
        72  Thread::Mutex

retained objects by gem
-----------------------------------
     11001  other

retained objects by file
-----------------------------------
     11001  ./leaky.rb

retained objects by location
-----------------------------------
     11000  ./leaky.rb:9
         1  ./leaky.rb:10

retained objects by class
-----------------------------------
     11000  String
         1  Thread::Mutex


Allocated String Report
-----------------------------------
     11000  "ABC"
     11000  ./leaky.rb:9


Retained String Report
-----------------------------------
     11000  "ABC"
     11000  ./leaky.rb:9

Вход в полноэкранный режим Выход из полноэкранного режима

Здесь содержится много информации, но в целом можно сказать следующее.

Будьте осторожны, доверяя подсчетам retained объектов. Они сильно зависят от того, какая часть кода утечки находится внутри блока report.

Например, если мы перенесем объявление an_array в блок report, мы можем обмануться, решив, что код не является негерметичным.

# leaky.rb
require "memory_profiler"

report = MemoryProfiler.report do
  an_array = []

  11.times do

    1000.times { an_array << "A" + "B" + "C" }
    puts "Array is #{an_array.size} items long"
  end

  GC.start
end

report.pretty_print
Вход в полноэкранный режим Выход из полноэкранного режима

В верхней части результирующего отчета не будет показано много сохраненных объектов (только сам отчет).

Total allocated: 529784 bytes (11002 objects)
Total retained:  72 bytes (1 objects)
Вход в полноэкранный режим Выход из полноэкранного режима

derailed_benchmarks в Ruby

Гем derailed_benchmarks — это набор очень полезных инструментов для всех видов работы с производительностью, в основном предназначенных для приложений Rails. Для поиска утечек мы хотим посмотреть на perf:mem_over_time, perf:objects и perf:heap_diff.

Эти задачи работают, посылая запросы curl в работающее приложение, поэтому мы не можем добавить их в нашу маленькую дырявую программу. Вместо этого нам нужно создать небольшое приложение Rails с конечной точкой, утекающей память, а затем установить derailed_benchmarks на это приложение.

# Create a rails app with no database
rails new leaky --skip-active-record --minimal

## Add derailed benchmarks
cd leaky
bundle add derailed_benchmarks
Вход в полноэкранный режим Выход из полноэкранного режима
# config/routes.rb
Rails.application.routes.draw do
  root "leaks#index"
end

# app/controllers/leaks_controller.rb
class LeaksController < ApplicationController
  def index
    1000.times { $an_array << "A" + "B" + "C" }

    render plain: "Array is #{$an_array.size} items long"
  end
end

# config/initializers/array.rb
$an_array = []
Войдите в полноэкранный режим Выйдите из полноэкранного режима

Теперь вы должны иметь возможность загрузить приложение с помощью bin/rails s. Вы сможете curl конечную точку, которая утекает при каждом запросе.

$ curl http://localhost:3000

Array is 1000 items long

$ curl http://localhost:3000

Array is 2000 items long
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы можем использовать derailed_benchmarks, чтобы увидеть нашу утечку в действии.

perf:mem_over_time.

Это покажет нам использование памяти с течением времени (аналогично тому, как мы наблюдали за ростом памяти нашего скрипта с утечкой с помощью watch и ps).

Derailed загрузит приложение в режиме производства, несколько раз обратится к конечной точке (/ по умолчанию) и сообщит об использовании памяти. Если оно не перестает расти, значит, у нас утечка!

$ TEST_COUNT=10000 DERAILED_SKIP_ACTIVE_RECORD=true 
  bundle exec derailed exec perf:mem_over_time

Booting: production
Endpoint: "/"
PID: 4417
104.33984375
300.609375
455.578125
642.69140625
751.6953125
Вход в полноэкранный режим Выход из полноэкранного режима

Примечание: Derailed загрузит Rails-приложение в производственном режиме для выполнения тестов. По умолчанию он также сначала require rails/all. Поскольку у нас нет базы данных в этом приложении, нам нужно переопределить это поведение с помощью DERAILED_SKIP_ACTIVE_RECORD=true.

Мы можем запустить этот бенчмарк на разных конечных точках, чтобы увидеть, какие из них (если таковые имеются) дают утечку.

perf:objects

Задача perf:objects использует memory_profiler под капотом, поэтому создаваемый отчет будет выглядеть знакомо.

$ TEST_COUNT=10 DERAILED_SKIP_ACTIVE_RECORD=true 
  bundle exec derailed exec perf:objects

Booting: production
Endpoint: "/"
Running 10 times
Total allocated: 2413560 bytes (55476 objects)
Total retained:  400000 bytes (10000 objects)

# The rest of the report...
Вход в полноэкранный режим Выход из полноэкранного режима

Этот отчет может помочь определить, где выделяется утечка памяти. В нашем примере последний раздел отчета — Retained String Report — говорит нам, в чем именно заключается проблема.

Retained String Report
-----------------------------------
     10000  "ABC"
     10000  /Users/tonyrowan/playground/leaky/app/controllers/leaks_controller.rb:3
Вход в полноэкранный режим Выход из полноэкранного режима

Мы слили 10 000 строк, содержащих «ABC» из LeaksController в строке 3. В нетривиальном приложении этот отчет был бы значительно больше и содержал бы сохраненные строки, которые вы хотите сохранить — кэши запросов и т.д. — но этот и другие разделы «по местоположению» должны помочь вам сузить круг поиска утечки.

perf:heap_diff

Эталон perf:heap_diff может помочь, если отчет из perf:objects слишком сложен, чтобы понять, откуда происходит утечка.

Как следует из названия, perf:heap_diff создает три дампа кучи и вычисляет разницу между ними. Он создает отчет, включающий типы объектов, сохранившихся между дампами, и место, которое их выделило.

$ DERAILED_SKIP_ACTIVE_RECORD=true bundle exec derailed exec perf:heap_diff

Booting: production
Endpoint: "/"
Running 1000 times
Heap file generated: "tmp/2022-06-15T11:08:28+01:00-heap-0.ndjson"
Running 1000 times
Heap file generated: "tmp/2022-06-15T11:08:28+01:00-heap-1.ndjson"
Running 1000 times
Heap file generated: "tmp/2022-06-15T11:08:28+01:00-heap-2.ndjson"

Diff
====
Retained STRING 999991 objects of size 39999640/40008500 (in bytes) at: /Users/tonyrowan/playground/leaky/app/controllers/leaks_controller.rb:3
Retained STRING 2 objects of size 148/40008500 (in bytes) at: /Users/tonyrowan/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/derailed_benchmarks-2.1.1/lib/derailed_benchmarks/tasks.rb:265
Retained STRING 1 objects of size 88/40008500 (in bytes) at: /Users/tonyrowan/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/derailed_benchmarks-2.1.1/lib/derailed_benchmarks/tasks.rb:266
Retained DATA 1 objects of size 72/40008500 (in bytes) at: /Users/tonyrowan/.asdf/installs/ruby/3.1.2/lib/ruby/3.1.0/objspace.rb:87
Retained IMEMO 1 objects of size 40/40008500 (in bytes) at: /Users/tonyrowan/.asdf/installs/ruby/3.1.2/lib/ruby/3.1.0/objspace.rb:88
Retained IMEMO 1 objects of size 40/40008500 (in bytes) at: /Users/tonyrowan/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/derailed_benchmarks-2.1.1/lib/derailed_benchmarks/tasks.rb:259
Retained IMEMO 1 objects of size 40/40008500 (in bytes) at: /Users/tonyrowan/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/derailed_benchmarks-2.1.1/lib/derailed_benchmarks/tasks.rb:260
Retained FILE 1 objects of size 8432/40008500 (in bytes) at: /Users/tonyrowan/.asdf/installs/ruby/3.1.2/lib/ruby/gems/3.1.0/gems/derailed_benchmarks-2.1.1/lib/derailed_benchmarks/tasks.rb:266

Run `$ heapy --help` for more options
Вход в полноэкранный режим Выход из полноэкранного режима

Вы также можете прочитать статью Отслеживание утечки памяти в Ruby в 2021 году, чтобы лучше понять, что происходит.

Отчет указывает нам именно на то, куда нам нужно идти для нашего приложения с утечкой памяти. В верхней части отчета мы видим 999991 сохраненный строковый объект, выделенный из LeaksController в строке 3.

Утечки в реальных приложениях Ruby и Rails

Надеюсь, что примеры, которые мы использовали до сих пор, никогда не попадали в реальные приложения — я надеюсь, что никто не собирается утекать память!

В нетривиальных приложениях утечки памяти отследить гораздо сложнее. Сохраняемые объекты не всегда плохи — кэш с собранным мусором не принесет большой пользы.

Однако есть нечто общее между всеми утечками. Где-то объект корневого уровня (класс/глобальный объект и т.д.) содержит ссылку на объект.

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

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

class Score < ApplicationModel
  def self.user_high_score(game, user)
    @scores = {} unless @scores

    if (score = @scores["#{game.id}:#{user.id}"])
      score
    else
      Score.where(game: game, user: user).order(:score).first.tap do |score|
        @scores["#{game.id}:#{user.id}"] = score
      end
    end
  end

  def self.save_score(game, user, raw_score)
    score = create!(game: game, user: user, score: raw_score)

    if raw_score > user_high_score(game, user).score
      @scores["#{game.id}:#{user.id}"] = score
    end
  end
end
Вход в полноэкранный режим Выход из полноэкранного режима

Хэш @scores совершенно не проверен. Он будет расти и хранить все высокие баллы для каждого пользователя — не идеально, если у вас их много.

В приложении Rails мы, вероятно, захотим использовать Rails.cache с разумным сроком действия (утечка памяти в Redis — это все равно утечка памяти!).

В приложениях, не использующих Rails, мы хотим ограничить размер хэша, вытесняя самые старые или недавно использованные элементы. LruRedux — хорошая реализация.

Более тонкая версия этой утечки — кэш с ограничением, но ключи которого имеют произвольный размер. Если сами ключи растут, то растет и кэш. Обычно вы не столкнетесь с этим. Но если вы сериализуете объекты в виде JSON и используете его в качестве ключа, дважды проверьте, не сериализуете ли вы вещи, которые также растут по мере использования — например, список прочитанных сообщений пользователя.

Циркулярные ссылки

Циркулярные ссылки могут быть собраны в мусор. Сборка мусора в Ruby использует алгоритм «Mark and Sweep». Во время презентации, посвященной распределению переменной ширины, Питер Жу и Мэтт Валентайн-Хаус отлично объяснили, как работает этот алгоритм.

По сути, есть две фазы: маркировка и сметание.

  • На этапе маркировки сборщик мусора начинает с корневых объектов (классы, глобальные объекты и т.д.), помечает их, а затем просматривает объекты, на которые они ссылаются.

Затем он помечает все ссылающиеся объекты. Ссылочные объекты, которые уже помечены, больше не просматриваются. Так продолжается до тех пор, пока не останется объектов для просмотра, т.е. пока не будут помечены все ссылающиеся объекты.

  • Затем сборщик мусора переходит к фазе очистки. Любой объект, не помеченный, очищается.

Поэтому объекты с живыми ссылками все еще могут быть очищены. До тех пор, пока корневой объект в конечном итоге не ссылается на объект, он будет собран. Таким образом, кластеры объектов с круговыми ссылками все еще могут быть собраны.

Мониторинг производительности приложений: Временная шкала событий и график выделенных объектов

Как уже упоминалось в первой части этой серии, любое приложение производственного уровня должно использовать ту или иную форму мониторинга производительности приложений (APM).

Существует множество вариантов, включая создание собственного (рекомендуется только для больших команд). Одна из ключевых функций, которую вы должны получить от APM, — это возможность видеть количество выделений, которые делает действие (или фоновое задание). Хорошие инструменты APM разбивают это на части, давая представление о том, откуда поступают выделения — из контроллера, представления и т.д.

Это часто называют «временной шкалой событий». Бонусные очки, если ваш APM позволяет вам писать пользовательский код, который еще больше разбивает временную шкалу.

Рассмотрим следующий код для контроллера Rails.

class LeaksController < ApplicationController
  before_action :leak

  def index
    @leaks = $leak.sample(100)
  end


  private

  def leak
    1000.times { $leak << Leak.new }
  end
end
Вход в полноэкранный режим Выход из полноэкранного режима

В отчете APM «временная шкала событий» может выглядеть примерно так, как показано на следующем скриншоте от AppSignal.

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

class LeaksController < ApplicationController
  before_action :leak

  def index
    Appsignal.instrument('leak.fetch_leaks') do
      @leaks = $leak.sample(100)
    end
  end


  private

  def leak
    return unless params[:leak]

    Appsignal.instrument('leak.create_leaks') do
      1000.times { $leak << Leak.new }
    end
  end
end

Вход в полноэкранный режим Выйти из полноэкранного режима

Вот пример инструментальной временной шкалы событий, опять же от AppSignal:

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

Если ваш APM показывает выполнение GC или выделение ресурсов с течением времени, вы можете обратить внимание на всплески, чтобы увидеть, совпадают ли они с определенными конечными точками или определенными запущенными фоновыми заданиями. Вот еще один пример из магической панели AppSignal для Ruby VM:

Рассматривая выделения таким образом, мы можем сузить круг поиска при
поиска проблем с памятью. Это значительно упрощает использование таких инструментов, как

Читайте о последних дополнениях к Ruby gem от AppSignal, таких как отслеживание распределения и статистики GC.

Подведение итогов

В этом посте мы рассмотрели некоторые инструменты, которые могут помочь найти и исправить утечки памяти, включая memory_profiler, derailed_benchmarks, perf:mem_over_time, perf:objects, perf:heap_diff, временную шкалу событий и график выделенных объектов в AppSignal.

Я надеюсь, что эта статья, а также первая часть были полезны вам для диагностики и устранения утечек памяти в вашем приложении на Ruby.

Читайте подробнее об инструментах, которые мы использовали:

  • memory_profiler
  • derailed_benchmarks
  • Утечка в приложении Rails

Дополнительное подробное чтение:

  • GC документация модуля
  • Документация модуля ObjectSpace
  • Глубокое погружение в сборку мусора
  • Распределение переменной ширины

Счастливого кодинга!

P.S. Если вы хотите читать посты Ruby Magic сразу после их выхода из печати, подпишитесь на нашу рассылку Ruby Magic и не пропустите ни одного поста!

Оцените статью
devanswers.ru
Добавить комментарий