Здравствуйте, коллеги.

Наши долгие поиски неустаревающих бестселлеров по оптимизации кода пока дают лишь первые результаты, но мы готовы вас порадовать, что буквально только что закончен перевод легендарной книги Бена Уотсона "Writing High Performance .NET Code". В магазинах — ориентировочно в апреле, следите за рекламой.

А сегодня предлагаем вам почитать сугубо практическую статью о наиболее насущных видах утечек оперативной памяти, которую написал Нельсон Ильхейдж (Nelson Elhage) из компании Stripe.

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

Тип (1): выделен недостижимый фрагмент памяти

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

void leak_memory() {
  char *leaked = malloc(4096);
  use_a_buffer(leaked);
  /* Упс, забыл вызвать free() */
}

Как определить, что утечка относится именно к этой категории

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

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

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

Тип (2): незапланированно долгоживущие выделения памяти

Такие ситуации не являются “утечками” в классическом смысле слова, так как ссылка откуда-нибудь на этот участок памяти все-таки сохраняется, поэтому в конце концов он может быть высвобожден (если программа успеет туда добраться, не израсходовав всю память).
Ситуации из этой категории могут возникать по многим специфическим причинам. Наиболее распространенные таковы:

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

Как определить, что утечка относится именно к этой категории

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

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

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

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

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

Утечки такого типа возникают в серой зоне, между памятью, которая считается «свободной» с точки зрения распределителя внутри VM или среды времени выполнения, и памятью, которая «свободна» с точки зрения операционной системы. Наиболее распространенная (но не единственная) причина такого явления – фрагментация кучи. Некоторые распределители попросту берут и не возвращают память в операционную систему после того как та была выделена.

Случай такого рода можно рассмотреть на примере короткой программы, написанной на 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-кб буферов, а затем сохраняем каждый последующий. Мы каждую секунду выводим состояние памяти с точки зрения операционной системы и с точки зрения собственного сборщика мусора Python.

У меня на ноутбуке получается примерно такой вывод:

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


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

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

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

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

Допустим, мы настроили систему, в которой:

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

Раз в минуту прибывает такой «китообразный» запрос, обработку которого мы поручаем одному из 10 работников, допустим, случайным образом: ~random. В идеале, на время обработки этого запроса данный работник должен выделить 1GB оперативной памяти, а после окончания работы вернуть эту память операционной системе, чтобы в дальнейшем ее можно было снова использовать. Чтобы неограниченно долго обрабатывать запросы по такому принципу, серверу потребуется всего 10 * 500MB + 1GB = 6GB RAM.

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

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

Как определить, что утечка относится именно к этой категории

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

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

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

  • Чаще перезапускайте ваши процессы. Если проблема нарастает медленно, то, возможно, перезапуск всех процессов приложения раз в 15 минут или раз в час может не составить никакого труда.
  • Еще более радикальный подход: можно научить все процессы перезапускаться самостоятельно, как только занимаемое ими пространство в памяти превышает некое пороговое значение или вырастает на заданную величину. Однако, постарайтесь предусмотреть, чтобы весь ваш парк серверов не мог пуститься в спонтанный синхронный перезапуск.
  • Поменяйте распределитель памяти. В долгосрочной перспективе tcmalloc и jemalloc обычно справляются с фрагментацией гораздо лучше, чем распределитель, задаваемый по умолчанию, а экспериментировать с ними очень удобно при помощи переменной LD_PRELOAD.
  • Выясните, есть ли у вас отдельные запросы, потребляющие гораздо больше памяти, чем остальные. У нас в Stripe серверы API измеряют RSS (постоянное потребление памяти) до и после обслуживания каждого запроса к API и логируют дельту. Затем мы без труда запрашиваем наши системы агрегации логов, чтобы определить, есть ли такие терминалы и пользователи (и прослеживаются ли закономерности), на которых можно списать всплески потребления памяти.
  • Отрегулируйте сборщик мусора/распределитель памяти. Во многих из них предусмотрены настраиваемые параметры, позволяющие задавать, насколько активно такой механизм будет возвращать память в операционную систему, насколько он оптимизирован на устранение фрагментации; там есть и другие полезные параметры. Здесь все также довольно сложно: убедитесь, что точно понимаете, что именно вы измеряете и оптимизируете, а также постарайтесь найти эксперта по соответствующей виртуальной машине и проконсультироваться с ним.

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


  1. picul
    04.12.2018 18:16
    +4

    Три вида утечек в памяти
    Обычно говорят «утечек памяти», без «в».


  1. qw1
    04.12.2018 19:12
    +1

    N single-threaded workers = N однопоточных работников?
    Переводчик точно в теме, о чём текст?


  1. AEP
    05.12.2018 05:38

    И еще «дамп ядра», когда имеется в виду «core dump».


  1. LAutour
    05.12.2018 06:29

    По первой утечке: можно ли освободить блок созданный malloc вне функции(возвращает указатель на него), где он бы создан? (Приходилось стлокнуться, когда надо было поправить чужой код.)


    1. svr_91
      05.12.2018 11:26

      Можно, но осторожно :)
      Вызов free должен быть в той же единице трансляции (по простому, cpp-файле), что и вызов malloc
      Например, можно создать дополнительную функцию

      char * leak_memory() {
        char *leaked = malloc(4096);
        use_a_buffer(leaked);
        return leaked;
      }
      void freeMemory(char *mem) {
       free(mem);
      }
      


      1. qw1
        05.12.2018 12:04
        +1

        Вызов free должен быть в той же единице трансляции (по простому, cpp-файле)
        Это ещё почему? Все obj-файлы линкуются с одним libc, и free из любой единицы трансляции вызовет тот же free.


        1. Siemargl
          05.12.2018 15:52

          Это так в случае статической линковки. В случае же динамической — может не выполняться.
          Потому переданный через границы DLL/SO указатель, надо освобождать функцией из этой же DLL


          1. qw1
            05.12.2018 18:07

            Верно, но в единицам трансляции это не имеет отношения.


        1. svr_91
          05.12.2018 17:12

          Да, возможно я погорячился насчет той же единицы трансляции. Скорее тут стоило бы сказать «в той же DLL». Но от моего совета вряд ли будет хуже


          1. qw1
            05.12.2018 18:09

            Можно уточнить до «того же экземпляра рантайма». Потому как если все DLL динамически слинкованы с некоей msvcrt.dll, то тоже можно выделять память в одной DLL и освобождать в другой. В Delphi для этого есть packages.

            Но от моего совета вряд ли будет хуже
            Создание мифов — да, безобидное дело )))


            1. Siemargl
              05.12.2018 19:47

              тоже неточно. может оказаться, что разными модулями используются разные версии msvcrt.dll, что также приведет к разным хипам

              нужно именно до «того же экземпляра рантайма»


  1. kuraga333
    05.12.2018 12:25

    В продолжение темы утечки типа 3: sourceware.org/bugzilla/show_bug.cgi?id=14827.

    В Glibc, при free участка памяти размером меньше M_MXFAST (константа времени компиляции), освобожденный участок не возвращается в пользование ОС. Пока malloc_trim не вызовешь, все эти кусочки, сколько бы их ни было, «занимают место»…

    До того, как я встретил это при работе с большой таблицей в Pandas (Python), к своему стыду, не задумывался, что «free dynamic memory» не включает, вообще говоря, «release free memory».

    Вообще, ненастраиваемость этой и связанных констант, а также ее дефолтное значение, считал неправильностью (особенно понимая, что это может быть причиной «у меня течет KDE/Chrome/etc. ну и, точно, моего кода на Pandas), но, благо, мне объяснили, что никто (free) в этом мире ничего никому не должен.

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