В первой части этой серии статей об утечках памяти мы рассмотрели, как 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 и не пропустите ни одного поста!