RSS не падает после free, и это не утечка
После пиковой нагрузки процесс держал два гигабайта резидентной памяти, хотя всё освободил. Я три дня искал утечку, не поверив прибору, который с первой попытки сказал правду: потерянных байт ноль.
Наблюдение
Сервис обрабатывает пачки документов: на пике держит в памяти около двух гигабайт, после обработки освобождает всё и ждёт следующую пачку. Проблема в том, что между пачками RSS оставался 2,0 ГиБ при 2,1 ГиБ на пике. Монитор узла считал это занятой памятью, и на четвёртом сервисе узел уходил в своп.
Три дня, потраченные на недоверие
Первым делом прогнал под valgrind и получил честное definitely lost: 0 bytes in 0 blocks. Я решил, что прибор чего-то не видит, потому что память-то вот она, занята. Потом прогнал massif, который показал пик выделений, совпадающий с ожидаемым, и спад до нуля в конце. Я и ему не поверил.
Это и была ошибка: оба прибора ответили правильно на вопрос «есть ли недостижимая невозвращённая память». Её не было. Я же искал ответ на другой вопрос - «почему ядро считает память занятой», и к утечкам он отношения не имеет.
Как на самом деле устроен возврат памяти
Аллокатор glibc берёт память у ядра двумя путями. Крупные запросы (от порога M_MMAP_THRESHOLD, по умолчанию он начинается со 128 КиБ и подстраивается вверх по ходу работы) идут через mmap и при освобождении возвращаются ядру сразу. Всё остальное живёт в арене, которая растёт через brk - и вот её можно уменьшить только с конца.
Если на самой верхушке арены остался хотя бы один живой блок, всё, что под ним, остаётся в адресном пространстве процесса, сколько бы там ни было свободного места. Порог M_TRIM_THRESHOLD (128 КиБ) определяет, с какого размера свободного хвоста аллокатор вообще пытается его отдать.
Чем это смотреть
Два источника, оба не требуют пересборки. Первый - сводка аллокатора прямо из процесса:
struct mallinfo2 mi = mallinfo2();
fprintf(stderr, "arena=%zu fordblks=%zu hblkhd=%zu keepcost=%zu\n",
mi.arena, mi.fordblks, mi.hblkhd, mi.keepcost);
arena=2 013 265 920 fordblks=1 911 619 584
hblkhd=100 663 296 keepcost=132 096
fordblks - 1,78 ГиБ свободно внутри аллокатора. То есть память не потеряна, она просто не отдана. А keepcost - всего 129 КиБ: это и есть размер того непрерывного хвоста, который теоретически можно вернуть прямо сейчас. Вот и ответ.
Второй источник - ядро, без участия процесса:
$ cat /proc/2841/smaps_rollup | head -5
Rss: 2097404 kB
Pss: 2093118 kB
Private_Dirty: 2064912 kB
Anonymous: 2061240 kB
Приватных грязных страниц два гигабайта - значит, это действительно наша анонимная память, а не отображённые файлы и не разделяемые страницы.
Проверка гипотезы одной строкой
malloc_trim(0); /* после освобождения пачки */
RSS до: 2 097 404 кБ
RSS после: 319 488 кБ
заняло: 11 мс
Полтора гигабайта вернулись за одиннадцать миллисекунд. Гипотеза подтверждена: это фрагментация, а не утечка.
Но вызывать malloc_trim по расписанию я не стал. Он проходит по всем аренам процесса - у нашего их девять при двенадцати потоках - и на это время берёт их блокировки. Одиннадцать миллисекунд паузы в обработчике запросов хуже, чем лишний гигабайт в простое.
Что сделал вместо
Убрал источник фрагментации. Пик набирался из буферов разного размера под документы: от 4 КиБ до 3 МиБ, десятки тысяч штук, вперемешку с мелкими узлами разбора. Именно это смешение и оставляет живые блоки на верхушке арены.
Вместо этого - пул буферов фиксированного размера 64 КиБ, заводимый один раз при старте; документы крупнее собираются из нескольких буферов. Мелкие узлы разбора ушли в арену с единым временем жизни, про которую я писал отдельно.
| режим | RSS на пике | RSS в простое |
|---|---|---|
| как было | 2,10 ГиБ | 2,00 ГиБ |
| с malloc_trim по таймеру | 2,10 ГиБ | 0,31 ГиБ |
| пул буферов, без trim | 0,41 ГиБ | 0,39 ГиБ |
Три разных слова, которые я теперь не путаю
- Утечка - память недостижима и не освобождена. Это ловит
valgrind, и именно это он у меня не нашёл. - Удержание - память достижима, но живёт дольше, чем нужно: кеш без вытеснения, забытая ссылка в списке. Приборы молчат, потому что формально всё правильно.
- Невозврат ядру - память свободна для аллокатора, но остаётся в
RSS. Лечится не поиском виновного указателя, а изменением размеров и времени жизни выделений.
И главное: RSS - это метрика ядра про резидентные страницы, а не метрика потребления программы. Когда прибор говорит «потерь нет», а память занята, скорее всего прибор прав, а вопрос задан не тот.
Комментарии (1)
vm - 20 марта 2024, 11:25
malloc_trim(0) помог мне ровно один раз, а потом дошло, что это фрагментация, а не удержание. Здесь всё по полкам, забираю в закладки.
Войти, чтобы комментировать.
Регистрация закрыта.