Почему освобождённая память не возвращается системе: как устроен malloc
Вы вызвали free() для гигабайта данных, приложение отчиталось, что объекты уничтожены, а top как показывал 2 ГБ RSS, так и показывает. Первая мысль — утечка памяти. Вторая, после часа с valgrind и пустыми руками — что-то не так с самим top. На самом деле не так ни то, ни другое: так и должен вести себя аллокатор памяти в Linux, и если вы администрируете долгоживущие процессы (базы, воркеры очередей, JVM, Node.js), полезно понимать, почему это происходит и как отличить нормальное поведение от настоящей утечки.
Содержание
Что вы на самом деле видите в top
Когда top или ps показывают потребление памяти процесса, это почти всегда RSS (Resident Set Size) — количество физических страниц памяти, которые прямо сейчас закреплены за процессом в таблице страниц ядра. Подробнее о том, чем RSS отличается от VSZ и почему эти цифры вообще не обязаны сходиться, разобрано в статье про разную память процесса в ps и top — но для этой темы важен один факт: RSS отражает, сколько памяти ядро выдало процессу, а не сколько из неё сейчас реально используется его кодом.
Между «используется приложением» и «закреплено за процессом» стоит целый слой — аллокатор памяти (в большинстве Linux-дистрибутивов это ptmalloc2, часть glibc, если явно не подключены jemalloc или tcmalloc). Когда ваш код вызывает free(), происходит не системный вызов и не мгновенный возврat страниц ядру, а внутренняя бухгалтерия: аллокатор помечает блок как свободный и кладёт его в собственные списки для повторного использования. Ядро в этот момент чаще всего вообще не уведомляется. Поэтому /proc/[pid]/status и top продолжают показывать прежний RSS — с точки зрения ядра эта память по-прежнему принадлежит процессу, просто внутри него она свободна.
Проверить это можно на живом примере: выделите и заполните крупный буфер, взгляните на VmRSS в /proc/self/status, освободите его — и в большинстве случаев цифра не изменится или изменится незначительно. Это не баг мониторинга, это архитектурное решение аллокатора.
Как устроен аллокатор: арены, чанки и списки свободных блоков
Аллокатор — это не тонкая прослойка над brk/mmap, а полноценный менеджер памяти внутри процесса. Он запрашивает у ядра большие куски памяти редко (системные вызовы дорогие: переключение контекста, работа с таблицей страниц, возможные page fault-ы), а внутри этих кусков сам нарезает блоки под конкретные malloc().
Базовые понятия ptmalloc2, которые стоит держать в голове:
- Chunk — единица памяти внутри кучи, чуть больше запрошенного размера: у каждого чанка есть служебный заголовок с размером и флагами, и именно поэтому
malloc(16)в реальности откусывает от кучи больше 16 байт. - Bins — списки свободных чанков, сгруппированные по размеру: fastbins (мелкие блоки, минимум служебной работы), smallbins и largebins (более крупные, с сортировкой по размеру), unsorted bin (промежуточный список для только что освобождённых чанков).
- tcache — начиная с glibc 2.26 у каждого потока есть собственный небольшой кэш свободных чанков, чтобы не лезть в общие бины при каждом
malloc/freeв горячем пути — это одна из причин, почему многопоточные приложения не спотыкаются о блокировки на каждой мелкой аллокации. - Arena — независимая область кучи со своим набором бинов. Главный поток работает с main arena, растущей через
brk/sbrk. Остальные потоки при конкуренции за main arena могут получить собственные арены, выделенные черезmmap, чтобы не блокировать друг друга. Верхний предел числа арен регулируется переменнойMALLOC_ARENA_MAX. - Coalescing — при освобождении чанка аллокатор пытается слить его с соседними свободными чанками в один большой блок, чтобы уменьшить фрагментацию и повысить шанс переиспользовать память под более крупный будущий запрос.
Ключевой вывод: free() в подавляющем большинстве случаев — это работа со связными списками внутри процесса, а не разговор с ядром. Память остаётся «прописана» за процессом и ждёт следующего malloc() того же или меньшего размера.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверbrk/sbrk: куча растёт одним непрерывным куском
Исторически куча процесса в Linux — это один непрерывный регион памяти, начинающийся сразу после сегментов данных программы и растущий вверх. Граница этого региона называется program break, а системные вызовы brk()/sbrk() двигают её вверх (запрашивая у ядра новые страницы) или вниз (возвращая их).
$ strace -e trace=brk ./my_app
brk(NULL) = 0x55d1a2a3f000
brk(0x55d1a2a60000) = 0x55d1a2a60000
...
Здесь и кроется главное ограничение этого механизма: подвинуть границу вниз можно только с самого верха кучи. Если вы освободили блок где-то в середине региона, а выше него по адресам всё ещё лежат живые объекты (даже один маленький), аллокатор физически не может сообщить ядру «верни эти страницы» — граница brk упирается в самый верхний живой чанк и не может пройти сквозь него. Освобождённый блок остаётся во внутренних бинах, ждёт переиспользования, а top продолжает показывать его в RSS процесса.
Это одна из главных причин, почему долгоживущие процессы с неравномерным паттерном аллокаций (то много мелких объектов, то один долгоживущий) годами «не отдают» память, даже не имея ни одной настоящей утечки — просто один «неудачно» расположенный живой объект блокирует возврат гигабайт памяти под ним.
mmap: отдельный путь для крупных выделений
Для больших выделений glibc использует другой механизм — вместо расширения непрерывной кучи через brk, аллокатор напрямую вызывает mmap() и создаёт отдельный анонимный маппинг под конкретный объект.
$ strace -e trace=mmap,munmap ./my_app
mmap(NULL, 2097152, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f2a10000000
...
munmap(0x7f2a10000000, 2097152) = 0
Порог, начиная с которого аллокация уходит через mmap вместо кучи, настраивается параметром M_MMAP_THRESHOLD (по умолчанию в районе 128 КБ, но glibc может динамически поднимать этот порог, если видит, что процесс регулярно выделяет и освобождает крупные блоки — конкретные значения зависят от версии glibc и поведения приложения, не закладывайтесь на точную цифру без проверки в вашем окружении).
Разница принципиальна: mmap-регион — это самостоятельный кусок адресного пространства, не связанный с общей кучей. Когда такой блок освобождается, аллокатор действительно вызывает munmap(), и страницы физически возвращаются ядру немедленно — это как раз тот редкий случай, когда top сразу покажет падение RSS после free(). Именно поэтому поведение «память не отдаётся» типично для приложений с множеством мелких и средних аллокаций (через кучу) и почти не проявляется у приложений, которые оперируют немногими крупными буферами (через mmap).
Почему аллокатор не спешит отдавать память ядру
Даже там, где технически возврат памяти возможен (свободный блок оказался на самом верху кучи), glibc не делает это при каждом free(). Есть управляемый порог M_TRIM_THRESHOLD — только когда объём свободного пространства на вершине кучи превышает этот порог, аллокатор выполняет trim и действительно сокращает brk. Это осознанный компромисс, а не недоработка:
- Системные вызовы не бесплатны. Каждый
brk/munmap— это переход в ядро, работа с таблицей страниц процесса, возможный TLB shootdown на многопроцессорной машине. Если приложение выделяет и освобождает память волнами (типичный паттерн для обработки запросов), возврат и повторный запрос одних и тех же страниц туда-обратно превращается в постоянные системные вызовы вместо пары операций со связным списком в userspace. - Аллокатор оптимизируется под повторное использование. Свежий
free()буфера с высокой вероятностью означает, что скоро придёт похожий по размеру запрос — веб-сервер обрабатывает следующий запрос, воркер берёт следующую задачу из очереди. Держать пул «про запас» дешевле, чем гонять память туда-сюда с ядром. - Фрагментация растёт снизу вверх. Пока где-то в куче остаются живые объекты выше освобождённого блока, само по себе желание «отдать память» ничего не меняет — вернуть получится только то, что физически оказалось на вершине.
- Overcommit со стороны ядра снижает цену этого решения. Linux по умолчанию выдаёт виртуальную память щедрее, чем реально готов подкрепить физическими страницами, и не требует немедленного возврата неиспользуемых регионов у каждого процесса. Логика этого механизма подробно разобрана в статье про overcommit памяти в Linux — она объясняет, почему ядро в принципе спокойно относится к тому, что процессы держат за собой «резерв».
Отдельно стоит многопоточность: если процесс использует несколько арен (что типично для многопоточных сервисов), у каждой арены свой набор бинов и свой резерв свободной памяти. Суммарный «неиспользуемый, но не возвращённый» объём растёт пропорционально числу арен — это одна из причин, по которой ограничение MALLOC_ARENA_MAX заметно снижает RSS у многопоточных JVM- или Go-совместимых процессов на серверах с большим числом ядер, без единой строки изменений в самом приложении.
Как реально мониторить память долгоживущего сервиса
Раз RSS не равен «реально нужной приложению памяти», мониторинг «RSS растёт — значит, утечка» даёт много ложных тревог. Практический подход:
- Смотрите на плато, а не на абсолютное число. У сервиса без утечек RSS обычно растёт в первые минуты-часы работы (прогрев кэшей аллокатора, JIT, пулы соединений), а затем выходит на более-менее стабильный уровень с колебаниями вокруг пиковой нагрузки. Настоящая утечка — это RSS, который продолжает расти после многих циклов нагрузки без возврата к базовому уровню.
- Сравнивайте RSS до и после принудительного trim. Функция
malloc_trim(0)заставляет glibc попытаться вернуть ядру всё, что можно вернуть с вершины кучи прямо сейчас. Вызвать её можно из кода в контрольной точке, либо через отладчик у уже работающего процесса:
$ gdb -p <PID> -batch -ex 'call malloc_trim(0)'
Если после этого RSS заметно падает — вы видели не утечку, а резерв аллокатора. Если не падает — стоит разбираться дальше.
- Используйте
malloc_stats()/malloc_info()для взгляда изнутри аллокатора. Эти функции печатают, сколько памяти реально занято живыми объектами по мнению самого аллокатора, а сколько лежит в свободных бинах — это прямой ответ на вопрос «RSS или используется, или зарезервирован». - Разделяйте RSS на приватную и разделяемую часть.
/proc/[pid]/smaps_rollupдаётPss(Proportional Set Size) и приватные/разделяемые страницы отдельно — полезно для процессов с разделяемыми библиотеками или fork-воркерами, где голый RSS завышает картину. - Для контейнеров сверяйтесь с cgroup-лимитом, а не только с RSS процесса. Если сервис работает под ограничением
memory.max, удержанный аллокатором «резерв» считается ядром как обычное потребление и приближает контейнер к OOM ровно так же, как реально используемые данные — подробности того, как ядро выбирает жертву при нехватке памяти, разобраны в статье про OOM killer и восстановление после его срабатывания. Практический вывод: лимит контейнера нужно закладывать с запасом не только под «полезные» данные, но и под типичный резерв аллокатора для вашего паттерна нагрузки. - Если резерв аллокатора мешает конкретно вам — смените аллокатор. jemalloc и tcmalloc, в отличие от классического ptmalloc2, по умолчанию агрессивнее возвращают память ОС и лучше справляются с фрагментацией у процессов с неоднородными размерами аллокаций (характерно для баз данных и рантаймов с GC). Подключаются обычно через
LD_PRELOADбез пересборки приложения, но перед заменой стоит прогнать реальную нагрузку в тестовом окружении — выигрыш по памяти иногда даётся ценой других компромиссов (например, чуть иного профиля задержек под пиковой конкурентной нагрузкой), и универсальных цифр здесь нет — зависит от вашего паттерна аллокаций. - Не полагайтесь на рестарт как на лечение симптома. Периодический перезапуск сервиса действительно сбрасывает RSS, но если причина — не утечка, а просто резерв аллокатора под вашу рабочую нагрузку, после прогрева RSS вернётся к тому же плато. Рестарт по расписанию маскирует реальные утечки не хуже, чем скрывает нормальное поведение аллокатора, — и то и другое стоит различать логами и графиками, а не интуицией.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Утечка памяти вообще существует, если top всё равно не показывает освобождённое?
Да, существует — но проявляется иначе: RSS растёт без ограничения при повторяющейся нагрузке и не выходит на плато даже после malloc_trim(0). Резерв аллокатора, в отличие от утечки, стабилизируется на конечном уровне.
Можно ли заставить glibc возвращать память сразу после каждого free?
Технически можно снизить M_TRIM_THRESHOLD через mallopt() почти до нуля, но на практике это часто ухудшает производительность из-за постоянных системных вызовов при типичном паттерне «выделил-освободил-выделил снова». Обычно правильнее вызывать malloc_trim() точечно, в контрольных точках (например, после обработки крупного пакового задания), а не менять глобальное поведение аллокатора.
Почему у многопоточного процесса RSS особенно сильно расходится с реально используемой памятью?
Из-за нескольких арен: каждый поток может получить собственную арену со своим пулом свободных чанков, и эти пулы не шарятся между потоками. Ограничение MALLOC_ARENA_MAX уменьшает число арен и, соответственно, суммарный резерв ценой чуть большей конкуренции потоков за общую кучу.
Это поведение специфично для Linux и glibc или так везде?
Механизм в целом похож в большинстве современных аллокаторов (Windows, macOS, альтернативные аллокаторы вроде jemalloc/tcmalloc) — все они держат внутренние пулы свободной памяти вместо немедленного возврата ОС, различаются только пороги, структуры данных и агрессивность возврата. Конкретные системные вызовы (brk vs VirtualAlloc и так далее) отличаются по платформам, но сама идея «не дёргать ядро по мелочам» универсальна.
Как понять, что именно держит память — куча или mmap-регионы?
Смотрите /proc/[pid]/maps или pmap -x <PID>: сегмент [heap] — это классическая куча через brk, а отдельные анонимные регионы с большими размерами и без имени файла обычно и есть прямые mmap-аллокации.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →