Керівництво по вирішенню проблем з пам'яттю у Ruby



Напевно є везучі 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, які могли стати причиною цієї проблеми з пам'яттю.

Розділяй і володарюй
Отже, якщо нам потрібно працювати з великими обсягами даних, то ми приречені втрачати купу оперативної пам'яті? На щастя, це не той випадок. Якщо взяти попередній приклад і зменшити розмір масиву, то ми виявимо, що споживання пам'яті вирівняється раніше.



Це означає, що якщо ми можемо розбити нашу роботу на більш дрібні фрагменти і обробити, щоб виключити одночасне використання великої кількості об'єктів, то ми уникнемо сильного збільшення споживання пам'яті. На жаль, для цього часто доводиться взяти чистий і красивий код і перетворити його в більш громіздкий, який робить все те ж саме, але ефективніше з точки зору пам'яті.

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

Виконаємо профілювання коду, щоб зрозуміти, де здійснюються розподілу пам'яті:

# 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



На кожній стадії ланцюжка виконується ітерація по всім елементам і створюється новий масив, у якого є викликається їм наступний метод в ланцюжку, і т. д. Давайте подивимося, що відбувається, коли ми робимо це ледачим способом, просто додаючи виклик
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), але в реальних ситуаціях це допускається тільки в рідкісних випадках.

Створювати перечіслімого для ледачої обробки даних, наприклад, при роботі з даними, розділеними на сторінки. Замість того щоб запитувати всі сторінки та класти їх в один великий масив, можна видавати їх через перечислитель, який чудово ховає всі подробиці розбиття на сторінки. Наприклад, так:

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, розглянули кілька основних інструментів для відстеження проблем з пам'яттю, розібрали часті ситуації і способи оптимізації. Розглянуті ситуації не претендують на повноту охоплення та ілюструють різні проблеми, з якими я особисто стикався. Проте головний результат статті — спроба задуматися про те, як код впливає на споживання пам'яті.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.