Memory leaks и fragmentation — valgrind, ASan и диагностика
«Программа жрёт память». Эту фразу слышал каждый dev и каждый ops-инженер. Сервер запускается с 200 МБ RSS, через неделю у него 4 ГБ, OOM killer убивает — и всё начинается заново. Что это? Memory leak — утечка памяти, когда объекты выделяются, но никогда не освобождаются. Или это fragmentation — мнимая утечка, когда суммарно памяти выделено много, но живых объектов мало. Лечатся они по-разному, и спутать их легко.
В этом уроке: чем отличается leak от fragmentation, как ловить каждое из них, и какие инструменты использовать — valgrind, AddressSanitizer (ASan), HeapTrack, Python tracemalloc, jemalloc stats. После этого урока вы сможете сесть за «жрущий» сервис и за пол-часа диагностировать, что именно с ним не так.
Leak vs fragmentation — два разных диагноза
Эти две беды часто путают, потому что симптом один: RSS процесса растёт. Но природа разная.
Memory leak — баг в коде. Объекты выделены (живая ссылка где-то держится) или просто потеряны (указатель потерян, но free не вызван). Аллокатор считает их живыми, kernel хранит страницы. Чем дольше работает программа, тем больше утекло. Часто экспоненциально по нагрузке.
Fragmentation — не баг, а особенность работы аллокатора. Программа честно malloc/free объекты, но из-за паттернов аллокации страницы держатся kernel-ом, хотя живых данных в них мало. Растёт RSS, но не растёт количество живых объектов в дампе.
Первый шаг диагностики — понять, что у вас. Снимите два heap dump-а с интервалом в час и сравните количество живых объектов. Если их количество растёт пропорционально — утечка. Если стоит на месте, а RSS растёт — фрагментация.
Valgrind — классика для C/C++
Самый известный инструмент для поиска утечек в C/C++ — valgrind (точнее, его инструмент memcheck). Работает как виртуальная машина: подменяет malloc/free, отслеживает каждый байт.
# Простая программа с утечкой:
cat > leak.c << 'CEOF'
#include <stdlib.h>
#include <string.h>
void leaky() {
char* buf = malloc(100);
strcpy(buf, "I am leaked");
// забыли free
}
int main(void) {
for (int i = 0; i < 5; i++) leaky();
return 0;
}
CEOF
gcc -g leak.c -o leak
valgrind --leak-check=full --show-leak-kinds=all ./leak
# Типичный вывод:
# ==12345== HEAP SUMMARY:
# ==12345== in use at exit: 500 bytes in 5 blocks
# ==12345== total heap usage: 5 allocs, 0 frees, 500 bytes allocated
# ==12345==
# ==12345== 500 bytes in 5 blocks are definitely lost in loss record 1 of 1
# ==12345== at 0x4C2AB80: malloc (vg_replace_malloc.c:309)
# ==12345== by 0x108691: leaky (leak.c:5)
# ==12345== by 0x1086B5: main (leak.c:13)
# ==12345==
# ==12345== LEAK SUMMARY:
# ==12345== definitely lost: 500 bytes in 5 blocks
Valgrind различает четыре вида:
- definitely lost — указатель потерян, никто не дойдёт до объекта. Чистый leak.
- indirectly lost — объект сам в указателях на другие объекты, и те «definitely lost» -> цепная реакция.
- possibly lost — указатель остался, но не на начало блока (например, сдвинут). Иногда нормально, иногда баг.
- still reachable — объект жив до конца программы, указатель есть. Обычно не баг (глобальный кэш, например).
Главный минус valgrind: программа под ним работает 10-50x медленнее. Прод-сервис не запустить под valgrind, только тесты или короткие сценарии.
Valgrind находит не только утечки. `memcheck` ловит чтение неинициализированной памяти, use-after-free, out-of-bounds, double-free. Это must-run для CI на C/C++. Стартовая команда — `valgrind —leak-check=full —track-origins=yes ./tests`.
AddressSanitizer — быстрая альтернатива
AddressSanitizer (ASan) — инструмент от Google, встроенный в gcc/clang. В отличие от valgrind, работает через compile-time инструментацию: компилятор расставляет проверки прямо в код. Накладные расходы — 2-3x по скорости, ~2x по памяти. Можно (с осторожностью) гонять под нагрузкой.
# Та же утечка под ASan:
gcc -g -fsanitize=address -fno-omit-frame-pointer leak.c -o leak_asan
./leak_asan
# Типичный вывод:
# =================================================================
# ==12345==ERROR: LeakSanitizer: detected memory leaks
#
# Direct leak of 500 byte(s) in 5 object(s) allocated from:
# #0 0x7f1234560123 in __interceptor_malloc
# #1 0x55a3b6e2b1a0 in leaky leak.c:5
# #2 0x55a3b6e2b1c5 in main leak.c:13
#
# SUMMARY: AddressSanitizer: 500 byte(s) leaked in 5 allocation(s).
ASan включает несколько инструментов:
- AddressSanitizer — buffer overflows, use-after-free, double-free.
- LeakSanitizer — утечки (входит в ASan по умолчанию).
- UndefinedBehaviorSanitizer (UBSan) — UB (integer overflow, null deref).
- ThreadSanitizer (TSan) — data races.
- MemorySanitizer (MSan) — чтение неинициализированной памяти.
Каждый — отдельная компиляция (-fsanitize=address, =undefined, =thread). Их часто включают в CI: каждый PR пробегается под ASan + UBSan.
# Найти buffer overflow:
cat > overflow.c << 'CEOF'
#include <stdlib.h>
int main(void) {
int* arr = malloc(10 * sizeof(int));
arr[10] = 42; // off-by-one, выход за границу
free(arr);
return 0;
}
CEOF
gcc -g -fsanitize=address overflow.c -o overflow_asan
./overflow_asan
# ASan напечатает heap-buffer-overflow с указанием строки
Python — tracemalloc и heap snapshots
В Python нет malloc/free на уровне пользователя — GC сам управляет. Но утечки бывают всё равно: вы где-то держите ссылку на объект, который думали освободить. Симптом тот же: программа жрёт память.
tracemalloc — встроенный модуль для трекинга аллокаций. Записывает, откуда какой объект пришёл.
python3 -c "
import tracemalloc
tracemalloc.start(10) # глубина стека 10 кадров
# Имитируем утечку: список всё растёт
cache = []
for i in range(10000):
cache.append(' ' * 1000)
snapshot = tracemalloc.take_snapshot()
top = snapshot.statistics('lineno')[:5]
for stat in top:
print(stat)
"
# Типичный вывод:
# <stdin>:9: size=9766 KiB, count=10000, average=1000 B
# ... другие линии с меньшими размерами
statistics('lineno') группирует аллокации по строке кода. Первый кандидат на исследование — самый «жирный».
Для production-сервиса tracemalloc можно включить через переменную окружения PYTHONTRACEMALLOC=10 (без изменения кода) и периодически снимать snapshots в файл.
Альтернатива — objgraph для визуализации связей между объектами:
pip install objgraph
python3 -c "
import objgraph
# что чаще всего создаётся:
objgraph.show_most_common_types(limit=10)
# конкретные объекты типа dict:
objgraph.show_growth(limit=10)
# граф ссылок на конкретный объект:
# objgraph.show_backrefs([obj], filename='backrefs.png')
"
HeapTrack — лёгкий tracker для production
HeapTrack — инструмент KDE, который перехватывает malloc/free через LD_PRELOAD. Накладные расходы малые (5-10%), можно гонять под нагрузкой.
# Установка:
sudo apt install heaptrack
# Запуск программы:
heaptrack ./my_server
# создаёт heaptrack.my_server.PID.gz
# Анализ:
heaptrack_print heaptrack.my_server.12345.gz | head -50
# или GUI:
heaptrack_gui heaptrack.my_server.12345.gz
HeapTrack строит граф аллокаций по времени и по функциям — видно, какие функции отвечают за рост памяти.
jemalloc stats — видим внутренности аллокатора
Если переключить программу на jemalloc, можно достать его внутреннюю статистику и понять, фрагментация это или нет:
# Запустить программу с jemalloc и активной телеметрией:
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 \
MALLOC_CONF=stats_print:true \
./my_server
# При завершении (или по сигналу) jemalloc напечатает:
# ___ Begin jemalloc statistics ___
# Allocated: 200 MB
# Active: 250 MB
# Mapped: 2000 MB
# Resident: 1800 MB
# Retained: 200 MB
# ...
Расшифровка ключевых полей:
- Allocated — сколько живых байт сейчас удерживает приложение (запросило через malloc и не вернуло).
- Active — сколько байт активно используется (включая внутренние округления).
- Mapped — сколько виртуальной памяти у kernel-а.
- Resident — сколько физической RAM (RSS).
Если Allocated == 200 MB, а Resident == 1800 MB — огромная фрагментация. Если Allocated == 1700 MB — утечка, не фрагментация.
# Триггерить дамп stats без перезапуска:
# jemalloc слушает SIGRTMIN+5 для подобного, но проще через JE_MALLOC_CONF и API.
# В Rust/C можно вызывать je_malloc_stats_print() из кода периодически.
Визуальная схема диагностики
Типичные паттерны утечек
Несколько паттернов, которые в природе встречаются чаще всего:
1. Cache без TTL. Глобальный dict с кэшем ответов API. Записываем, но никогда не удаляем. Через сутки в нём 10 миллионов записей.
# Плохо:
_cache = {}
def fetch(key):
if key not in _cache:
_cache[key] = api_call(key)
return _cache[key]
Решение: functools.lru_cache(maxsize=N) или внешний кэш (Redis с TTL).
2. Подписки на события без отписки. Регистрируем callback, но не снимаем при удалении объекта. Объект продолжает жить, потому что слушатель удерживает ссылку.
3. Замыкания через closure. Лямбда захватила локальную переменную (например, response), та держит большой объект -> ссылка живёт пока живёт лямбда.
# Плохо: closure захватывает большой объект
responses = []
def make_handler():
big_data = load_huge_dataset() # 100 МБ
def handler(req):
return big_data[req.key] # closure ссылается на big_data
responses.append(handler) # responses теперь держит big_data
4. Циклические ссылки в C-extensions. GC Python ловит обычные циклы, но если объекты в C-уровне (numpy, sqlalchemy session) — GC может их не найти.
5. Логирование с накоплением. Logging-handler собирает события в памяти и сбрасывает на диск. Если диск тормозит — буфер растёт неограниченно.
В Python код вида `logging.getLogger().addHandler(handler)` без проверки ‘уже добавлен ли’ — частый источник утечек в hot-reload системах. Каждый reload добавляет новый handler, старые не убираются.
Попробуй сам
Соберите простой leak detector в Python:
python3 << 'EOF'
import gc, tracemalloc
tracemalloc.start(5)
# Имитация запросов
cache = {}
def fake_request(key):
cache[key] = ' ' * 10000
for i in range(100):
fake_request(f'key-{i}')
snap1 = tracemalloc.take_snapshot()
# Ещё нагрузки
for i in range(100, 200):
fake_request(f'key-{i}')
snap2 = tracemalloc.take_snapshot()
# Diff покажет, где появились новые байты:
diff = snap2.compare_to(snap1, 'lineno')[:5]
for stat in diff:
print(stat)
EOF
Вы увидите, какая строчка кода добавила сколько байт между snapshots. Это базовый паттерн для prod-диагностики: periodically снимать snapshot, diff-ить.
Для C-программ запустите свой код под ASan:
gcc -g -fsanitize=address -fno-omit-frame-pointer your_program.c -o asan_build
./asan_build
# Если есть утечки -- ASan напечатает их при exit
Включите ASan в CI — это копеечно по времени, ловит 90% багов с памятью до прод-деплоя.