Learning Platform
Глоссарий Troubleshooting
Урок 08.03 · 22 мин
Начальный
MemoryLeaksValgrindAddressSanitizerDebugging

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, но не растёт количество живых объектов в дампе.

Leak vs Fragmentation -- два разных профиля
Leak: alloc++, free=0Утечка: программа выделяет и не освобождает. Кол-во живых объектов растёт линейно с временем работы. Suspicious -- посмотреть, какие объекты в heap dump
RSS grows, live objects growПри утечке: RSS растёт И count живых объектов растёт. Профайлер показывает конкретные объекты, которые накапливаются
Frag: alloc==free, RSS upФрагментация: malloc/free сбалансированы, но память накапливается у аллокатора. Heap dump показывает мало живых -- но RSS большой
RSS grows, live objects steadyПри фрагментации: RSS большой, а live objects -- те же 50 МБ что и в начале. Разница (3 ГБ - 50 МБ) -- свободные дыры между живыми объектами

Первый шаг диагностики — понять, что у вас. Снимите два 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, только тесты или короткие сценарии.

TIP

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') группирует аллокации по строке кода. Первый кандидат на исследование — самый «жирный».

Инструменты измерений памяти: tracemalloc, pympler, sys.getsizeof

Для 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() из кода периодически.

Визуальная схема диагностики

Диагностика проблем с памятью -- от симптома к инструменту
Symptom: RSS growsЗаметили в мониторинге, что RSS процесса растёт. Это базовый сигнал, дальше надо понять, что именно растёт
Snapshot 1: live objects countСнять Python tracemalloc / gc.get_objects() / heap dump. Зафиксировать число живых объектов и их типы. Это baseline
Snapshot 2: 1 hour laterПодождать час под нагрузкой. Снять ту же статистику. Сравнить с baseline
Compare: same N?Если кол-во живых объектов того же типа растёт -- это утечка. Если стоит на месте, а RSS растёт -- фрагментация
Leak: profileУтечка: запустить tracemalloc, valgrind или heaptrack для нахождения origin. Найти место, где объекты создаются и не убираются
Frag: try jemallocФрагментация: попробовать сменить аллокатор на jemalloc. Если помогло -- было оно. Также рассмотреть max_requests рестарт workerов

Типичные паттерны утечек

Несколько паттернов, которые в природе встречаются чаще всего:

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 собирает события в памяти и сбрасывает на диск. Если диск тормозит — буфер растёт неограниченно.

WARNING

В 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% багов с памятью до прод-деплоя.


Проверка знанийKnowledge check
Production-сервис на Python (uvicorn + FastAPI) показывает рост RSS с 500 МБ до 2 ГБ за 24 часа под одинаковой нагрузкой. Heap dumps показывают, что число живых объектов стабильно ~100K. `gc.collect()` ничего не освободил. Каковы возможные причины и как локализовать?
ОтветAnswer
Раз кол-во живых объектов стабильно, а RSS растёт -- это либо фрагментация на уровне glibc, либо memory leak в C-расширении (numpy buffers, sqlalchemy session pool, sqlite3 statements), который Python tracemalloc не видит. Шаги диагностики: 1) Включить tracemalloc через PYTHONTRACEMALLOC=10 и снять snapshot.statistics('filename') -- это покажет, какие .py файлы аллоцируют. Если из стандартных модулей идёт мало -- утечка не в Python-коде. 2) Поглядеть RSS через /proc/PID/smaps_rollup на разные регионы: если `Anonymous: 1.5 GB` и `File-backed: 200 MB` -- это куча, и проблема в аллокаторе либо C-extension. 3) Запустить под valgrind короткий sanity-проверочный скрипт (массив реквестов), valgrind покажет утечки на C-уровне. 4) Попробовать LD_PRELOAD=libjemalloc -- если RSS стабилизируется на меньшем числе, это была фрагментация ptmalloc2. 5) Поставить gunicorn с max_requests=10000 как палка-выручалка -- каждый worker рестартует, RSS сбрасывается. Это standard workaround в Python-проде, не лечит причину но снимает симптом. 6) Если найдена закономерность с типом запросов -- профайлить тот endpoint под tracemalloc.start(25) с большой глубиной и сравнить snapshot до и после серии запросов: diff покажет, где аллоцированные байты задерживаются.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. В чём ключевое различие между memory leak и fragmentation?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4