Три вида утечек памяти

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

Тип 1: недостижимое выделение памяти

Это классическая утечка памяти в C/C++. Кто-то выделил память с помощью new или malloc и никогда не вызывал free или delete, чтобы освободить память после того, как с ней закончил работу.

void leak_memory() {
  char *leaked = malloc(4096);
  use_a_buffer(leaked);
  /* Whoops, forgot to call free() */
}

Как понять, что речь идет об этой категории?

  • Если вы пишете на C или C++, особенно на C++, и не используете повсеместно умные указатели для управления временем жизни памяти, это первый признак.
  • Если вы работаете в среде со сборкой мусора, возможно, расширение нативного кода имеет утечку этого типа, но вы должны сначала исключить типы (2) и (3).

Как найти утечку?

  • Используйте ASAN. Используйте ASAN. Используйте ASAN.
  • Используйте альтернативный инструмент для поиска утечки. Я использовал Valgrind или инструменты кучи tcmalloc, а в других средах есть и другие инструменты.
  • Некоторые распределители памяти могут выгружать профиль кучи, содержащий все несвободные распределения. Если вы используете утечку, то по прошествии достаточного количества времени почти все активные распределения будут получены из-за утечки, поэтому ее легко обнаружить.
  • Если ничего не помогает, возьмите дамп ядра и проанализируйте его. Но никогда не начинайте с использования этого инструмента.

Тип 2: распределение памяти с неожиданно долгим временем жизни

Эти утечки не являются «утечками» в классическом смысле, потому что на память откуда-то могут идти ссылки, и она может быть даже в конечном счете освобождена.

К этой категории относятся многие специфические ситуации. Некоторые общие признаки таковы:

  • Непреднамеренное накапливание состояний в глобальной структуре; например HTTP-сервер, который помещает каждый полученный объект запроса в глобальный список.
  • Кэши без соответствующей политики истечения срока действия. Например, кэш ORM, который кэширует каждый когда-либо загруженный объект, активный в ходе выполнения задачи по миграции данных, загружающей каждую запись в таблице.
  • Захват слишком большого количества состояний внутри замыкания. Это особенно распространено в JavaScript, но касается не только этой среды.
  • В более широком смысле — непреднамеренное удерживание каждого элемента массива или потока, когда кажется, что вы обрабатываете его в режиме передачи онлайн-потока.

Как понять, что речь идет об этой категории?

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

Как найти утечку?

Используйте инструменты для профилирования или формирования дампа кучи, предоставляемые вашей средой. Я знаю о guppy в Python или memory_profiler в Ruby, или попробуйте недавно написанный мной скрипт на Ruby ObjectSpace.

Тип 3: свободная, но неиспользованная или неиспользуемая память

Эту категорию сложнее всего отличить, но ее также очень важно знать и понимать.

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

Можно продемонстрировать эту концепцию с помощью следующей короткой программы на Python:

import sys
from guppy import hpy
hp = hpy()

def rss():
    return 4096 * int(open('/proc/self/stat').read().split(' ')[23])

def gcsize():
    return hp.heap().size

rss0, gc0 = (rss(), gcsize())

buf = [bytearray(1024) for i in range(200*1024)]
print("start rss={}   gcsize={}".format(rss()-rss0, gcsize()-gc0))
buf = buf[::2]
print("end   rss={}   gcsize={}".format(rss()-rss0, gcsize()-gc0))

Мы выделяем 200 000 буферов по 1 Кб, а затем отбрасываем каждый второй. Для каждой точки выводим информацию об использовании памяти, полученную от  операционной системы (RSS*) и сборщика мусора в Python.

На моем ноутбуке вывод выглядит примерно так:

start rss = 232222720 gcsize = 11667592
end rss = 232222720 gcsize = 5769520

Мы можем видеть, что Python фактически освободил половину буферов, о чем свидетельствует уменьшение gcsize почти до половины пикового значения, но ему не удалось вернуть ни одного байта операционной системе. Оставшаяся память доступна для использования этим Python-процессом, но не каким-либо другим процессом на этой машине.

Такие свободные, но неиспользуемые сегменты, могут создавать или не создавать проблемы. Если программа на Python так делает, а затем продолжает выделять больше 1 Кб, пространство будет использоваться повторно, и все будет хорошо.

Но если это сделать во время установки и дополнительно минимально выделить память, или если все будущие распределения памяти будут размером 1,5 Кб и не смогут поместиться в оставшиеся буферы по 1 Кб, вся память может навсегда остаться непригодной.

Среды, в которых этот класс проблем особенно ярко выражен, — многопроцессорные серверные среды таких языков, как Ruby или Python.

Предположим, есть такая конфигурация:

  • На каждом сервере выполняется N однопоточных воркеров для параллельной обработки запросов. Пусть будет N = 10 для конкретики.
  • В обычной ситуации каждый воркер использует примерно постоянный объем памяти. Пусть для конкретики будет 500 Мб.
  • С некоторой невысокой скоростью поступают запросы, которые требуют гораздо больше памяти, чем среднестатистический запрос. Для конкретики предположим, что раз в минуту мы получаем запрос, требующий дополнительно 1 Гб памяти во время выполнения, которая освобождается по завершении запроса.

Раз в минуту приходит запрос-гигант и назначается одному из 10 воркеров, скорее всего, случайным образом. В идеале этот воркер должен выделить 1 Гб ОЗУ во время выполнения запроса, а затем освободить эту память в ОС, чтобы она была доступна для последующего использования. Серверу в целом понадобится 10 * 500 Мб + 1 Гб = 6 Гб ОЗУ, чтобы обрабатывать такой запрос без перебоев.

Однако давайте представим, что из-за фрагментации кучи или по другой причине виртуальная машина не может освободить память для операционной системы. То есть память, необходимая ей от ОС, равна наибольшему объему памяти, который когда-то потребовался одномоментно. Теперь, когда конкретный процесс обслуживает запрос с большим объемом памяти, объем памяти этого процесса увеличивается на 1 Гб — навсегда.

При запуске сервера вы увидите следующий расход памяти: 10 * 500 МБ = 5 Гб. Как только поступит первый запрос-гигант, один воркер увеличит расход памяти на 1 Гб, которые не вернутся обратно. Использование памяти в целом увеличится до 6 Гб. При поступлении каждого нового запроса, а иногда они будут попадать в процесс, который раньше обслуживал запрос-гигант, и использование памяти остается неизменным, но иногда оно также попадает на нового воркера, а общее использование памяти будет увеличиваться еще на 1 ГБ до тех пор, пока каждый воркер не выполнит этот запрос хотя бы один раз, и вы израсходуете 10 * (500 Гб + 1 Гб) = 15 Гб ОЗУ, что намного больше, чем наши идеальные 6 Гб! Кроме того, если посмотреть на расход памяти во времени, вы увидите медленное увеличение с 5 до 15 Гб, что будет очень похоже на «настоящую» утечку.

Как понять, что речь идет об этой категории?

Сравните размер кучи, сообщаемый вашим распределителем памяти, с показателем RSS*, полученным от операционной системы. Если проблема относится к типу (3), эти числа будут иметь тенденцию с течением времени расходиться.

  • Мне нравится, что мои серверы приложений периодически передают оба числа в мою инфраструктуру временных рядов для упрощения построения графиков.
  • В Linux можно получить оценку ОС, используя 24-е поле из /proc/self/stat, и оценку распределителя памяти из языка/API виртуальной машины.

Как найти утечку?

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

  • Перезапускайте свои процессы чаще. Если проблема медленно нарастает, перезапуск всех процессов приложения каждые 15 минут или раз в час может стать выходом. Почти все крупные магазины на Ruby или Python, с которыми я общаюсь, в конечном счете так делают.
  • В качестве более агрессивной меры вы можете заставить воркеров самостоятельно перезагружаться, если расходуемая ими память пересечет пороговое значение или определенный показатель роста. Обязательно предусмотрите, чтобы весь кластер не начал самопроизвольно перезагружаться в синхронном режиме.
  • Используйте другой распределитель памяти. Как tcmalloc, так и jemalloc, как правило, имеют значительное преимущество в показателях по фрагментации во времени, чем распределитель по умолчанию, и с ними очень легко экспериментировать, используя LD_PRELOAD.
  • Поищите отдельные запросы, которые занимают намного больше памяти, чем остальные. В Stripe наши серверы API измеряют RSS* до и после обработки каждого запроса API и регистрируют дельту. Затем в системах агрегирования логов можно легко определить, причастны ли определенные конечные точки, пользователи или что-то другое к скачкам памяти.
  • Настройте свой сборщик мусора/распределитель. Многие GC или распределители содержат настраиваемые параметры, с помощью которых можно контролировать, насколько настойчиво будет предприниматься попытка вернуть память в ОС, какая будет произведена оптимизация для предотвращения фрагментации, или другие полезные параметры. Это сложная область. Прежде чем начать, убедитесь, что вы знаете, что измеряете и оптимизируете, и по возможности найдите и проконсультируйтесь со специалистом по используемоей вами виртуальной машине.

Примечания

* «Размер страницы памяти» — объем оперативной памяти, фактически используемый программой, в отличие от размера виртуальной памяти или другой статистики, которая может затрагивать ОС.


Автор статьи — Nelson Elhage, перевод — Евгений Зятев.