У нас в Phusion работает простой многопоточный HTTP-прокси на Ruby (раздаёт пакеты DEB и RPM). Я видел на нём потребление памяти 1,3 ГБ. Но это безумие для stateless-процесса…


Вопрос: Что это? Ответ: Использование памяти процессом Ruby с течением времени!

Оказывается, я не одинок в этой проблеме. Приложения Ruby могут использовать много памяти. Но почему? Согласно Heroku и Нейту Беркопеку, в основном раздутие связано с фрагментацией памяти и чрезмерным распределением по кучам.

Беркопек пришёл к выводу, что существует два решения:

  1. Либо используйте совершенно другой распределитель памяти, чем в glibc — обычно jemalloc, либо:
  2. Установите магическую переменную среды MALLOC_ARENA_MAX=2.

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

Магия — это просто наука, которую мы пока не понимаем. Поэтому я отправился в исследовательское путешествие, чтобы узнать всю правду. В этой статье осветим следующие темы:

  1. Как работает распределение памяти.
  2. Что это за «фрагментация» и «чрезмерное распределение» памяти, о которых все говорят?
  3. Что вызывает большое потребление памяти? Ситуация соответствует тому, что говорят люди, или есть что-то ещё? (спойлер: да, есть кое-что ещё).
  4. Существуют ли альтернативные решения? (спойлер: я нашёл одно).

Примечание: статья актуальна только для Linux, и только для многопоточных приложений на Ruby.

Содержание



Распределение памяти в Ruby: введение


Распределение памяти в Ruby происходит на трёх уровнях, сверху вниз:

  1. Интерпретатор Ruby, который управляет объектами Ruby.
  2. Библиотека распределителя памяти операционной системы.
  3. Ядро.

Пройдёмся по каждому уровню.

Ruby


На своей стороне Ruby организует объекты в областях памяти, называемых страницами кучи Ruby. Такая страница кучи разбита на слоты одинакового размера, где один объект занимает один слот. Будь то строка, хеш-таблица, массив, класс или что-то ещё, он занимает один слот.



Слоты на странице кучи могут быть заняты или свободны. Когда Ruby выделяет новый объект, тот сразу пытается занять свободный слот. Если свободных слотов нет, то будет выделена новая страница кучи.

Слот небольшой, около 40 байт. Очевидно, что некоторые объекты в него не поместятся, например, строки по 1 МБ. Тогда Ruby сохраняет информацию в другом месте за пределами страницы кучи, а в слот помещает указатель на эту внешнюю область памяти.


Данные, которые не помещаются в слот, хранятся вне страницы кучи. Ruby помещает в слот указатель на эти внешние данные

Как страницы кучи Ruby, так и любые внешние области памяти выделяются с помощью распределителя памяти системы.

Системный распределитель памяти


Распределитель памяти операционной системы является частью glibc (среда выполнения C). Он используется почти всеми приложениями, а не только Ruby. У него простой API:

  • Память выделяется вызовом malloc(size). Вы передаёте ему количество байт, которое хотите выделить, а он возвращает либо адрес выделения, либо ошибку.
  • Выделенная память освобождается вызовом free(address).

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

В свою очередь, распределитель памяти обращается к API ядра. Он забирает из ядра гораздо большие куски памяти, чем запрашивают его собственные абоненты, поскольку вызов ядра дорогостоящий и у API ядра есть ограничение: оно может выделять память только кратно 4 КБ.


Распределитель памяти выделяет большие куски — они называются системные кучи — и делит их содержимое для удовлетворения запросов из приложений

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

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


Ruby выделяет память из распределителя памяти, который, в свою очередь, выделяет её из ядра

Ядро


Ядро может выделять память только юнитами по 4 КБ. Один такой блок 4 КБ называется страницей. Чтобы не путать со страницами кучи Ruby, для ясности будем использовать термин системная страница (OS page).

Причину сложно объяснить, но так работают все современные ядра.

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

Определение использования памяти


Таким образом, память выделяется на нескольких уровнях, и каждый уровень выделяет больше памяти, чем ему действительно нужно. На страницах кучи Ruby могут быть свободные слоты, как и в системных кучах. Поэтому ответ на вопрос «Сколько памяти используется?» полностью зависит от того, на каком уровне вы спрашиваете!

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

Что такое фрагментация?


Фрагментация памяти означает, что выделения памяти беспорядочно разбросаны. Это может вызвать интересные проблемы.

Фрагментация на уровне Ruby


Рассмотрим сборку мусора Ruby. Сборка мусора для объекта означает маркировку слота страницы кучи Ruby как свободного, что позволяет его повторно использовать. Если вся страница кучи Ruby состоит только из свободных слотов, то всю её целиком можно освободить обратно в распределитель памяти (и, возможно, обратно в ядро).



Но что произойдёт, если свободны не все слоты? Что делать, если у нас много страниц кучи Ruby, а сборщик мусора освобождает объекты в разных местах, так что в конечном итоге остаётся много свободных слотов, но на разных страницах? В такой ситуации у Ruby есть свободные слоты для размещения объектов, но распределитель памяти и ядро продолжат выделять память!

Фрагментация на уровне распределителя памяти


У распределителя памяти похожая, но совсем другая проблема. Ему не нужно освобождать сразу целые системные кучи. Теоретически, он может освободить любую отдельную системную страницу. Но поскольку распределитель памяти имеет дело с выделениями памяти произвольного размера, на системной странице может находиться несколько выделений. Он не может освободить системную страницу, пока не освободятся все выделения.



Подумайте, что произойдёт, если у нас есть выделение на 3 КБ, а также выделение на 2 КБ, разбитое на две системные страницы. Если вы освободите первые 3 КБ, обе системные страницы останутся частично заняты и не могут быть освобождены.



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

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

Является ли фрагментация страниц кучи Ruby причиной раздутия памяти?


Вполне вероятно, что фрагментация является причиной чрезмерного использования памяти в Ruby. Если так, то какая из двух фрагментаций наносит больший вред? Это…

  1. Фрагментация страниц кучи Ruby? Или
  2. Фрагментация распределителя памяти?

Первый вариант достаточно просто проверить. Ruby предоставляет два API: ObjectSpace.memsize_of_all и GC.stat. Благодаря этой информации можно подсчитать всю память, которую Ruby получила от распределителя.



ObjectSpace.memsize_of_all возвращает память, занятую всеми активными объектами Ruby. То есть всё место в своих слотах и любые внешние данные. На приведённой выше диаграмме это размер всех синих и оранжевых объектов.

GC.stat позволяет узнать размер всех свободных слотов, т. е. всю серую область на иллюстрации выше. Вот алгоритм:

GC.stat[:heap_free_slots] * GC::INTERNAL_CONSTANTS[:RVALUE_SIZE]

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

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



это… просто… безумие!

Результат показывает, что Ruby настолько слабо влияет на общий объём используемой памяти, что не имеет значения, фрагментированы страницы кучи Ruby или нет.

Придётся искать виновника в другом месте. По крайней мере, теперь мы знаем, что Ruby не виновата.

Исследование фрагментации на уровне распределителя памяти


Ещё один вероятный подозреваемый — распределитель памяти. В конце концов, Нейт Беркопек и Heroku заметили, что возня с распределителем памяти (либо полная замена на jemalloc, либо установка магической переменной среды MALLOC_ARENA_MAX=2) резко снижает использование памяти.

Давайте сначала посмотрим, что делает MALLOC_ARENA_MAX=2 и почему это помогает. Затем исследуем фрагментацию на уровне распределителя.

Чрезмерное распределение памяти и glibc


Причина, почему помогает MALLOC_ARENA_MAX=2, связана с многопоточностью. Когда несколько потоков одновременно пытаются выделить память из одной и той же системной кучи, они борются за доступ. Только один поток за раз может получить память, что снижает производительность многопоточного распределения памяти.


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

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

Фактически, максимальное количество системных куч, выделенных таким образом, по умолчанию равно количеству виртуальных процессоров, умноженному на 8. То есть в двухъядерной системе с двумя гиперпотоками на каждом получается 2 * 2 * 8 = 32 системные кучи! Это то, что я называю чрезмерным распределением.

Почему множитель по умолчанию такой большой? Потому что ведущий разработчик распределителя памяти — Red Hat. Их клиенты — большие компании с мощными серверами и тонной оперативной памяти. Вышеуказанная оптимизация позволяет повысить среднюю производительность многопоточности на 10% за счёт значительного увеличения использования памяти. Для клиентов Red Hat это хороший компромисс. Для большинства остальных — вряд ли.

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

Визуализация системных куч


Верно ли утверждение Нейта и Heroku, что увеличение количества системных куч увеличивает фрагментацию? На самом деле, есть ли вообще проблема с фрагментацией на уровне распределителя памяти? Я не хотел принимать ни одно из этих предположений как должное, поэтому начал исследование.

К сожалению, не существует инструментов для визуализации системных куч, поэтому я сам написал такой визуализатор.

Во-первых, нужно как-то сохранить схему распределения системных куч. Я изучил исходники распределителя памяти и посмотрел, как он внутренне представляет память. Далее написал библиотеку, которая перебирает эти структуры данных и записывает схему в файл. Наконец, написал инструмент, который берёт такой файл в качестве входных данных и компилирует визуализацию в виде изображений HTML и PNG (исходный код).



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

  • Красные области — используемые ячейки памяти.
  • Серые — свободные области, не выпущенные обратно в ядро.
  • Белые области освобождены для ядра.

Из визуализации можно сделать следующие выводы:

  1. Существует определённая фрагментация. Красные пятна разбросаны по памяти, а некоторые системные страницы красные только наполовину.
  2. К моему удивлению, большинство системных куч содержат значительное количество полностью свободных системных страниц (серые)!

И тут меня осенило:

Хотя фрагментация остаётся проблемой, но дело не в ней!

Скорее, проблема в большом количестве серого цвета: это распределитель памяти не отдаёт память обратно в ядро!

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

Волшебный трюк: обрезание


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

Я знал об этой функции, но не думал, что она полезна, потому что в руководстве сказано следующее:

Функция malloc_trim() пытается освободить свободную память в верхней части кучи.

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

Что произойдёт, если вызывать эту функцию во время сборки мусора? Я изменил исходный код Ruby 2.6, чтобы вызывать malloc_trim() в функции gc_start из gc.c, например:

gc_prof_timer_start(objspace);
{
    gc_marks(objspace, do_full_mark);
    // BEGIN MODIFICATION
    if (do_full_mark)
    {
        malloc_trim(0);
    }
    // END MODIFICATION
}
gc_prof_timer_stop(objspace);

И вот результаты теста:



Какая большая разница! Простой патч уменьшил потребление памяти почти до уровня MALLOC_ARENA_MAX=2.

Вот как всё выглядит в визуализации:



Мы видим много белых областей, которые соответствуют системным страницам, освобождённым обратно в ядро.

Заключение


Оказалось, что фрагментация, в основном, ни при чём. Дефрагментация по-прежнему полезна, но основная проблема заключается в том, что распределитель памяти не любит освобождать память обратно в ядро.

К счастью, решение оказалось очень простым. Главное было найти первопричину.

Исходный код визуализатора


Исходный код

Что насчёт производительности?


Производительность оставалась одним из главных опасений. Вызов malloc_trim() не может обходиться бесплатно, а по коду алгоритм работает в линейном времени. Поэтому я обратился к Ною Гиббсу, который запустил бенчмарк Rails Ruby Bench. К моему удивлению, патч вызвал небольшое увеличение производительности.





Это взорвало мой разум. Эффект непонятный, но новость хорошая.

Нужно больше тестов


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

Комментарии (2)


  1. Tresor
    20.03.2019 12:42

    Интересно, спасибо.


  1. SergeiD
    21.03.2019 06:17

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