MAATRIX / Блог / Фрагментация памяти: почему процесс, который живёт месяц, толстеет без утечек

Фрагментация памяти: почему процесс, который живёт месяц, толстеет без утечек

MAATRIX

Вы смотрите на график потребления памяти сервисом, который работает третью неделю без перезапуска, и видите ровный, упрямый рост. Утечки нет — вы уже проверили valgrind'ом или встроенным профилировщиком, все объекты освобождаются, счётчики аллокаций и деаллокаций сходятся. И всё равно процесс, который в первый день после старта занимал 400 МБ, сегодня занимает 900, а через месяц ляжет в OOM. Это не мистика и не баг в вашем коде — это фрагментация памяти внутри процесса, и она подчиняется вполне понятной механике аллокатора.

Куча — это не единый склад, а стеллажи разной ширины

Когда программа просит память через malloc(), аллокатор (в большинстве Linux-дистрибутивов это glibc с реализацией на основе ptmalloc2, но логика похожая и у jemalloc, и у tcmalloc) не выделяет каждый байт индивидуально. Он управляет большим непрерывным регионом памяти — кучей — и нарезает из него куски под конкретные запросы. Куски эти складываются, освобождаются и снова выделяются в произвольном порядке, который диктует логика вашей программы, а не аллокатора.

Представьте склад с полками. Если вы кладёте на полку сначала коробку 10×10 см, потом 30×30, потом 15×15, а через час забираете коробку 30×30 — на её месте остаётся дыра ровно под 30×30. Если следующая коробка, которую нужно положить, размером 32×32, она туда не влезет, даже если суммарно свободного места на складе с избытком. Аллокатор в такой ситуации либо ищет другую дыру (менее эффективно), либо просит у ОС новый кусок памяти через brk()/sbrk() или mmap() — и склад расширяется, хотя старая дыра так и осталась пустовать.

Именно так выглядит фрагментация кучи: сумма свободной памяти внутри процесса может быть значительной, но она раздроблена на кусочки, которые по отдельности слишком малы или слишком разбросаны, чтобы удовлетворить новый крупный запрос. Программа не течёт — она честно освобождает всё, что должна. Но геометрия дыр со временем становится всё менее удобной, и аллокатор компенсирует это тем, что просит у ядра ещё физической памяти.

Почему это не путается с фрагментацией диска, хотя звучит похоже

Фрагментация файловой системы на диске — это разбросанность блоков одного файла по разным местам носителя, что замедляет последовательное чтение. Фрагментация кучи процесса — совсем другая история: она не про скорость доступа (обращение к любому байту виртуальной памяти стоит одинаково), а про то, что свободное пространство внутри процесса становится непригодным для повторного использования.

Разница принципиальная и в последствиях. Фрагментированный диск лечится дефрагментацией «на месте» — блоки файла физически переставляются. Фрагментированную кучу процесса *на месте* починить почти невозможно: аллокатор в общем случае не умеет двигать уже выданные указатели, потому что где-то в программе на них есть ссылки, и переезд объекта сломает эти ссылки. Единственный надёжный способ полной дефрагментации — выделить всё заново в новом, чистом адресном пространстве. А новое чистое адресное пространство — это, по сути, перезапуск процесса.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Как со временем растут "дыры": роль размера и порядка освобождения

Ключевой фактор фрагментации — чередование выделений разного размера. Если процесс выделяет память пачками одинакового размера и освобождает их тоже пачками (классический паттерн пула объектов), фрагментация почти не растёт: дыры получаются одинакового размера и легко переиспользуются под новые запросы того же размера. Проблема начинается там, где размеры запросов вперемешку — то маленькая строка, то средний буфер, то временный крупный массив, — а порядок освобождения не совпадает с порядком выделения.

Типичный сценарий на реальном сервере:

  • обработчик HTTP-запроса выделяет буфер под тело запроса (размер плавает от килобайт до десятков мегабайт);
  • параллельно живут долгоживущие структуры — кэши, сессии, соединения с базой — которые не освобождаются вовсе или освобождаются намного позже;
  • короткоживущие объекты чередуются с долгоживущими в одной и той же области кучи.

Когда короткоживущий объект зажат между двумя долгоживущими, при его освобождении образуется дыра, которую долгоживущие соседи не дадут "срастить" с соседними свободными участками. Со временем таких зажатых дыр накапливается всё больше, а долгоживущие объекты (никогда не перемещаясь) продолжают держать кучу растянутой. Это классическое описание проблемы, которую в литературе называют memory fragmentation при перемежающихся (interleaved) шаблонах выделения — она хорошо изучена ещё с ранних работ по аллокаторам вроде dlmalloc.

Дополнительный эффект: аллокаторы обычно не возвращают освобождённые страницы ядру немедленно и не всегда способны вернуть их вообще, если в верхней части кучи (top of heap, "brk chunk") остаются занятые блоки — arena может расти только с одного конца через brk, и застрявший наверху объект блокирует усадку всего диапазона. Даже когда возврат страниц ядру технически возможен, раздробленность делает регионы для возврата более мелкими и рваными, а значит менее вероятными к фактическому madvise(MADV_DONTNEED) или sbrk с отрицательным приращением.

RSS растёт, а "живых" данных не прибавляется — как это увидеть

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

$ cat /proc/<PID>/status | grep -E 'VmRSS|VmData|VmHWM'
VmHWM:    912340 kB
VmRSS:    905112 kB
VmData:   940288 kB

VmHWM (high water mark) — исторический пик резидентной памяти, VmRSS — текущий. Если утечки нет, но VmRSS растёт день за днём и почти достиг VmHWM (то есть процесс не откатывается вниз даже во время затишья по нагрузке) — это сигнал в пользу фрагментации, а не активной утечки, у которой обычно есть характерный монотонный наклон без плато.

Дальше стоит заглянуть внутрь аллокатора, если это glibc. У процессов с ptmalloc есть недокументированный, но рабочий способ выгрузить статистику через malloc_stats() (если вы можете подключить gdb к процессу или добавить сигнальный обработчик, который её вызывает) или через mallinfo2():

struct mallinfo2 mi = mallinfo2();
printf("arena (всего в куче): %zu\n", mi.arena);
printf("uordblks (реально используется): %zu\n", mi.uordblks);
printf("fordblks (свободно, но не возвращено ОС): %zu\n", mi.fordblks);

Если arena большая, uordblks (то, что реально занято живыми данными) остаётся стабильным, а fordblks (формально свободные, но не возвращённые ОС блоки) растёт — это прямое подтверждение фрагментации: аллокатор честно сообщает, что держит память "про запас" внутри своих структур, просто не может (или пока не сумел) отдать её обратно ядру.

Для процессов на управляемых рантаймах (JVM, .NET, Go, Node.js) картина немного другая — там свой аллокатор поверх системного, и у каждого есть собственные метрики фрагментации кучи: у JVM это доля fragmentation в отчётах G1GC (G1 Evacuation Pause, Metaspace), у Go — runtime.MemStats.HeapIdle минус HeapReleased, у Node.js — process.memoryUsage().external в связке с heapUsed. Идея универсальна: сравнивайте объём "живых" данных, о которых знает сама программа, с тем, что видит ОС снаружи через RSS.

Почему это особенно заметно у долгоживущих процессов

Фрагментация — эффект, накапливающийся со временем, а не разовое событие. У процесса, который живёт секунды или минуты (классический короткий воркер, обработавший один batch-job и завершившийся), кучу просто не успевает раздробить — она вся уходит ОС при выходе процесса. Проблема проявляется именно там, где процесс живёт долго: демоны, воркеры очередей, приложения на PHP-FPM/Node.js/Java с долгоживущими worker-процессами, базы данных, к которым долго держат соединения.

Усиливающие факторы, которые стоит проверить в своём случае:

  • Неоднородность размеров запросов. Сервис, обрабатывающий запросы очень разного веса (маленькие JSON и большие файловые загрузки в одном процессе), фрагментируется быстрее сервиса с однородной нагрузкой.
  • Долгоживущие объекты вперемешку с короткоживущими. Кэш в памяти процесса, который живёт часами, соседствуя в куче с объектами, живущими миллисекунды, — питательная среда для дыр.
  • Число потоков и arena на поток. У glibc по умолчанию каждому потоку может достаться собственная arena (до 8 × число_ядер на 64-битных системах), и фрагментация в каждой arena растёт независимо — иногда суммарный оверхед от множества частично пустых arena заметнее, чем фрагментация внутри одной. Ограничить это можно переменной окружения MALLOC_ARENA_MAX, но это компромисс: меньше arena — меньше оверхед по памяти, но больше конкуренция за блокировку аллокатора между потоками.
  • Долгие интервалы между "затишьями". Если у сервиса не бывает окон низкой нагрузки, когда куча могла бы сжаться естественным образом, фрагментация только накапливается.

Что реально можно сделать, не трогая исходный код

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

Сменить аллокатор. jemalloc и tcmalloc спроектированы так, чтобы бороться с фрагментацией активнее, чем классический ptmalloc — за счёт более мелкой классификации размеров блоков (size classes) и более агрессивного возврата страниц ядру. Подключается через LD_PRELOAD без пересборки:

$ LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./your-service

Это не серебряная пуля — на некоторых нагрузочных профилях jemalloc сам по себе занимает больше памяти на старте (за счёт более крупных внутренних структур), поэтому эффект стоит проверить на копии прод-трафика, а не переключать вслепую.

Настроить пороги glibc. Переменная M_MMAP_THRESHOLD (или вызов mallopt(M_MMAP_THRESHOLD, ...)) определяет, с какого размера аллокация уходит не в кучу через brk, а напрямую в отдельный mmap-регион. Крупные и переменные по размеру аллокации, вынесенные в mmap, при освобождении возвращаются ОС сразу и не фрагментируют основную арену:

mallopt(M_MMAP_THRESHOLD, 131072); // всё крупнее 128 КБ — через mmap

Периодически звать malloc_trim(). Эта функция glibc пытается вернуть ядру неиспользуемые страницы с "верхушки" кучи. Она не устраняет фрагментацию внутри уже занятых регионов, но снимает часть накопленного "воздуха", если вызывать её в окнах низкой нагрузки (например, по крону в 3-4 часа ночи через сигнал приложению).

Ограничить и мониторить через cgroup. Даже если фрагментация неизбежна, разумный memory.max в cgroup v2 не даст одному раздутому процессу увести память соседей на том же хосте — подробнее об этом в статье как cgroups ограничивают контейнер и что происходит при упоре в лимит. Полезно смотреть именно на разницу между RSS процесса и тем, что видят инструменты вроде ps/top — эти два числа не всегда совпадают по методике подсчёта, и это отдельная тема, разобранная в статье почему ps и top показывают разную память одного процесса.

Плановый рестарт — не признак бага, а инженерное решение

Здесь и находится главный практический вывод: если куча процесса физически не умеет самостоятельно "сжаться" после того, как в ней образовались рассеянные дыры, а сменить аллокатор или переписать паттерны выделений в моменте нельзя, единственный надёжный способ вернуть память ОС — выделить кучу заново. Это и есть перезапуск процесса.

Именно поэтому у зрелых продакшн-систем плановый рестарт долгоживущих воркеров — это не заплатка от беспомощности, а осознанная часть эксплуатации, встроенная в саму архитектуру:

  • у PHP-FPM есть параметр pm.max_requests, который перезапускает воркер после N обработанных запросов именно по этой причине;
  • у Gunicorn (Python) есть --max-requests с тем же назначением;
  • у systemd можно повесить таймер на плановый systemctl restart сервиса в окно низкой нагрузки;
  • у Kubernetes есть readiness/liveness пробы и естественная ротация подов, которая на практике выполняет ту же функцию, даже если изначально задумывалась для других целей.

Важная оговорка: плановый рестарт — это управление симптомом, а не диагностика первопричины. Если память после рестарта возвращается к прежнему уровню через час, а не через недели — это уже не типичная медленная фрагментация, а что-то более активное (реальная утечка, аномально большой единичный запрос, взрывной рост кэша без границ), и здесь стоит смотреть в сторону других причин. Фрагментация даёт медленный, растянутый во времени рост RSS без резких скачков — если график выглядит иначе, это повод для отдельного расследования, а не повод чаще рестартовать. И трезво оценивайте частоту: если сервис и так переживает деплои каждую неделю, отдельный механизм обычно не нужен — деплой сам решает задачу за счёт естественной ротации процессов.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как отличить фрагментацию от настоящей утечки памяти на живом сервере?

Утечка обычно даёт монотонный рост без плато даже в затишье по нагрузке, и объём "живых" данных, о которых знает сама программа (счётчики объектов, mallinfo2().uordblks), тоже растёт. При фрагментации uordblks остаётся стабильным, а растёт fordblks — формально свободная, но не возвращённая ОС память; VmRSS при этом может подолгу держаться на плато, слегка проседая и снова заполняя тот же уровень.

Помогает ли malloc_trim() вызывать чаще — раз в минуту, а не раз в сутки?

Частый вызов сам по себе не увеличивает эффективность дефрагментации — он лишь чаще пытается вернуть верхушку кучи ядру, которая в моменте может быть пустой. Более важно вызывать его в окнах реально низкой нагрузки, а не по фиксированному расписанию без учёта трафика — иначе можно просто тратить CPU на бесполезные попытки.

Стоит ли менять аллокатор на jemalloc/tcmalloc заранее, "на всякий случай", если проблемы с памятью ещё не было?

Не обязательно — смена аллокатора добавляет свою специфику поведения (иные пороги, иногда больший базовый оверхед на старте) и её стоит тестировать на копии нагрузки, а не применять профилактически. Разумнее сперва подтвердить фрагментацию по метрикам (mallinfo2, соотношение RSS к живым данным), а уже потом выбирать инструмент под конкретный профиль аллокаций.

Можно ли вообще избавиться от фрагментации, если переписать код правильно?

Полностью — вряд ли, это фундаментальное свойство динамического выделения памяти произвольных размеров в произвольном порядке. Но снизить её сильно можно: группировать объекты похожего размера и похожего времени жизни (пулы объектов, арены под конкретный тип задач), выносить крупные временные буферы в отдельные mmap-регионы, не смешивать долгоживущие кэши с короткоживущими структурами запроса в одной области памяти.

Нужно ли беспокоиться о фрагментации на сервисе, который перезапускается при каждом деплое несколько раз в неделю?

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

Обсудить статью, задать вопрос или начать новую тему

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

Перейти в сообщество →