voidbytes.dev

системное программирование вслух: аллокаторы, сисколлы, профиль, разбор падений - и места, где я сам намерил неправильно

6 минпамять

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 КиБ) определяет, с какого размера свободного хвоста аллокатор вообще пытается его отдать.

до malloc_trim: RSS 2,0 ГиБ brk этот хвост можно вернуть ядру свободно, но брошено внутри кучи после malloc_trim: RSS 312 МиБ brk закрашено - живые блоки; дырки внутри остаются в RSS в обоих случаях
Отдать можно только непрерывный свободный хвост до brk. Дырки между живыми блоками остаются резидентными: адресное пространство уже есть, страницы уже тронуты, ядру их никто не возвращает.

Чем это смотреть

Два источника, оба не требуют пересборки. Первый - сводка аллокатора прямо из процесса:

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 ГиБ
пул буферов, без trim0,41 ГиБ0,39 ГиБ

Три разных слова, которые я теперь не путаю

И главное: RSS - это метрика ядра про резидентные страницы, а не метрика потребления программы. Когда прибор говорит «потерь нет», а память занята, скорее всего прибор прав, а вопрос задан не тот.

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

vm - 20 марта 2024, 11:25

malloc_trim(0) помог мне ровно один раз, а потом дошло, что это фрагментация, а не удержание. Здесь всё по полкам, забираю в закладки.

Войти, чтобы комментировать.

Регистрация закрыта.