Менеджер памяти в руби: жди подвоха

в 15:32, , рубрики: memory leaks, memory management, ruby

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

Менеджер памяти руби одновременно и элегантен, и не лишен странностей, в любую минуту готовых обернуться неприятным сюрпризом. Руби хранит объекты (RVALUE) в так называемой куче (это не heap в классическом понимании c-программистов, у руби своя куча). На низком уровне, RVALUE — это обычная c-struct, состоящая из области «атрибутов» и собственно области «данных». Подробный рассказ о том, как внутри хранятся объекты выходит за рамки данной заметки; достаточно просто понимать, что каждому объекту в руби соответствует такой вот RVALUE. Сборщик мусора считает ссылки на него, и очищает слот, когда счетчик обнуляется. В чисто академических целях скажу, что размер каждого такого слота зависит от архитектуры:

/*
 *  sizeof(RVALUE) is
 *  20 if 32-bit, double is 4-byte aligned
 *  24 if 32-bit, double is 8-byte aligned
 *  40 if 64-bit
 */

Итак, у нас есть слоты (это принятое название, slot), размером около сорока байт, в которых хранятся… Вот тут и начинается самое интересное. Там не просто хранятся ссылки на объекты, размещенные в настоящей системной куче, как, наверное, подумали люди, знакомые с реализацией более традиционных сборщиков мусора.

В большинстве случаев, когда мы имеем дело с обычным объектом (скажем, экземпляром класса User) — все происходит как во всех других языках. Счетчик обнулился, память из системной кучи вернулась (посредством стандартного вызова free). Но если размещаемый объект помещается в слот, никакая дополнительная память выделена не будет. Руби просто запишет объект в слот. Вот как эта разница выглядит в синтаксисе псевдо-си:

   char *s = malloc(42); // объект, не поместившийся в слот
   char s[5];            // объект, поместившийся в слот

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

Давайте взглянем на простой пример:

def report
puts 'Memory ' + `ps ax -o pid,rss | grep -E "^[[:space:]]*#{$$}"`
        .strip.split.map(&:to_i)[1].to_s + 'KB'
end

report
big_var = " " * 10_000_000
report
big_var = nil
report
ObjectSpace.garbage_collect
sleep 1
report

# ⇒ Memory 11788KB
# ⇒ Memory 65188KB
# ⇒ Memory 65188KB
# ⇒ Memory 11788KB

Тут мы хапнули памяти под здоровенную строку, попользовались ей и вернули память обратно операционной системе. Все отлично. Давайте теперь попробуем немного модифицировать код:

-  big_var = " " * 10_000_000
+  big_var = 1_000_000.times.map(&:to_s)

Ерундовое изменение, правда ведь? — Нет, неправда.

# ⇒ Memory 11788KB
# ⇒ Memory 65188KB
# ⇒ Memory 65188KB
# ⇒ Memory 57448KB

Какого черта? Память системе не возвращается (да, этот процесс теперь будет жить с почти шестьюдесятью метрами оперативки до самой смерти, даже если вообще ничего не будет делать). На самом деле, все логично. Каждое из чисел от одного до миллиона умещается в слот. А слотов по умолчанию (примерно, вот тут подробнее):

RUBY_HEAP_MIN_SLOTS=800000

Да и мы, все-таки, не единственные пользователи памяти. На самом деле, это все не так страшно. Для подавляющего большинства задач слотов хватит, особенно учитывая, что они возвращаются системе по мере того, как перестают быть востребованы. Например, если перезапустить код из примера выше два раза подряд (в одном процессе, разумеется), то дополнительно к уже отобранной у системе памяти он ничего не потребует: уже выделенных на первом проходе слотов хватит. Значение GC[:heap_used] уменьшится сразу после обнуления счетчика использования big_var. Память возвращается менеджеру памяти руби без проблем. Менеджеру памяти руби, а не операционной системе — вот об этом нельзя забывать.

Вообще, нужно быть аккуратным, создавая много временных переменных малого размера:

big_var = " " * 10_000_000
big_var.gsub(/s/) { |c| '-' }

Этот код тоже отожрет кусок памяти у системы для слотов и закуклится:

# ⇒ Memory 10156KB
# ⇒ Memory 13788KB
# ⇒ Memory 13788KB
# ⇒ Memory 12808KB

Это не смертельно, но об этом сто́ит помнить.

Еще из всего сказанного вытекает интересное следствие: не нужно создавать ruby symbols длиннее 23 символов (сюда подпадают имена переменных и методов), потому что память для них будет выделяться вызовом malloc, а не в готовом слоте. Но это так уже, таракан на торте.

Автор: mudasobwa

Источник


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


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