Профилирование памяти в Python: чек-лист диагностики
Процесс на Python съел 4 ГБ и упал по OOM, а вы не знаете, что именно течёт. Чек-лист: чем измерить рост памяти, где искать утечку и как отделить фрагментацию от реальной проблемы.

Воркер Celery стартует на 200 МБ, через двое суток занимает 3,5 ГБ и падает по OOM Killer. Рестарт по расписанию маскирует проблему, но не лечит. Чтобы найти причину, нужно измерять память, а не гадать. Ниже — порядок действий: что запускать первым, как отличить утечку от нормального роста и где в CPython память не возвращается системе даже после освобождения объектов.
Проверено на CPython 3.11–3.12 под Linux. Инструменты: tracemalloc из стандартной библиотеки, внешние memray и objgraph. Права root не нужны, но для чтения /proc процесс должен быть вашим.
Шаг 0: измерить, а не догадаться
Прежде чем ставить профайлеры, посмотрите на два числа: сколько памяти видит операционная система и сколько занимают живые объекты Python. Расхождение между ними — уже диагноз.
RSS1 процесса под Linux берётся из /proc/PID/status, строка VmRSS, или через psutil:
import os, psutil
proc = psutil.Process(os.getpid())
# RSS в мегабайтах — сколько физической памяти держит процесс
print(f"RSS: {proc.memory_info().rss / 1024 / 1024:.1f} MB")
Если RSS растёт линейно со временем — это либо утечка, либо кеш без ограничения. Если растёт до плато и стоит — возможно, просто фрагментация аллокатора, а не течь. Разница принципиальная: во втором случае искать нечего.
Чек-лист диагностики
- Снять базовый RSS сразу после старта и запомнить.
- Воспроизвести рост на минимальной нагрузке — один тип запроса, одна задача в цикле.
- Сравнить два снимка живых объектов через
tracemallocс интервалом. - Найти, что удерживает объекты, — через
objgraphили ссылки от GC. - Отделить фрагментацию: если объектов Python мало, а RSS велик — дело в аллокаторе или C-расширениях.
Шаг 1: tracemalloc — где выделяется память
tracemalloc входит в стандартную библиотеку и показывает, из какой строки кода выделены живые объекты. Он видит только аллокации самого Python — память, выделенную C-библиотеками (numpy, драйверы БД), он не покажет. Это первое ограничение, о которое спотыкаются.
import tracemalloc
tracemalloc.start()
snapshot_1 = tracemalloc.take_snapshot()
# ... прогнать нагрузку: обработать пачку запросов или задач ...
snapshot_2 = tracemalloc.take_snapshot()
# сравниваем два снимка, сортируем по приросту строк кода
diff = snapshot_2.compare_to(snapshot_1, 'lineno')
for stat in diff[:10]:
print(stat)
В выводе — файл, номер строки и прирост в байтах. Строка, которая растёт от снимка к снимку, и есть источник. Учтите накладные расходы: tracemalloc сам ест память и замедляет процесс в разы, поэтому в проде его держат выключенным и включают точечно.
Шаг 2: memray — включая C-расширения
memray от Bloomberg перехватывает аллокации на уровне C и показывает то, что tracemalloc пропускает. Запуск не требует правки кода:
# записать профиль в бинарный файл
python -m memray run -o profile.bin your_script.py
# построить flame graph по записанному профилю
python -m memray flamegraph profile.bin
Результат — HTML с flame graph: видно стек вызовов и сколько памяти удерживает каждая ветка. Флаг для поиска именно утечек — --leaks в команде flamegraph: он показывает то, что не освобождено к концу работы. Точный набор подкоманд и флагов сверьте в документации вашей версии memray, они между релизами дополняются.
Шаг 3: кто держит объект
Вы нашли, что растёт список словарей. Следующий вопрос — кто мешает сборщику их убрать. objgraph строит граф ссылок:
import objgraph
# сколько объектов каждого типа сейчас в памяти — топ по приросту
objgraph.show_growth(limit=10)
# найти цепочку ссылок, удерживающую конкретный объект
suspect = find_leaking_object() # ваш способ получить подозрительный объект
objgraph.show_backrefs([suspect], max_depth=5, filename='refs.png')
Частый виновник — объект, попавший в глобальный список, кеш модуля или в замыкание, которое живёт дольше, чем вы думали. Ещё один — циклические ссылки на объектах с __del__: до Python 3.4 сборщик не мог их разорвать, сейчас может, но такие циклы всё равно откладывают освобождение.
Линейный рост RSS — это ещё не утечка. Это симптом. Утечка — когда объекты остаются достижимыми и сборщик не имеет права их убрать. Кеш без лимита выглядит точно так же на графике, но лечится одной строкой maxsize.
Фрагментация: RSS не падает, хотя объекты освобождены
Классическая ловушка. Вы обработали большой пакет данных, объекты собраны сборщиком, tracemalloc показывает мало живых объектов — а RSS так и стоит на 2 ГБ. Это не течь.
CPython выделяет мелкие объекты через свой аллокатор pymalloc блоками-аренами. Арена возвращается системе только когда полностью пуста. Если в арене остался хоть один живой объект, вся арена — обычно 256 КБ2 — остаётся за процессом. При фрагментированной куче RSS не снижается, хотя формально памяти свободно.
Что с этим делать:
- Не пытаться «отдать память системе» вызовом
gc.collect()— он собирает мусор, но арены не дефрагментирует. - Тяжёлую разовую обработку выносить в отдельный процесс через
multiprocessing: процесс завершился — вся его память вернулась системе гарантированно. - Для воркеров ставить рестарт по числу задач (в Celery —
worker_max_tasks_per_child): это не лечит причину, но удерживает RSS в рамках.
Что чем измерять
| Инструмент | Что видит | Overhead | Когда брать |
|---|---|---|---|
psutil / /proc | RSS процесса целиком | Нулевой | Первый замер, мониторинг тренда |
tracemalloc | Аллокации Python по строкам кода | Высокий (в разы) | Локализовать строку-источник |
memray | Аллокации Python и C-расширений | Средний | Утечка в numpy, драйвере БД, C-коде |
objgraph | Граф ссылок между объектами | Средний | Понять, кто удерживает объект |
gc (стдлиб) | Циклические ссылки, статистика GC | Низкий | Подозрение на циклы с __del__ |
Если не получилось
- tracemalloc показывает мало памяти, а RSS огромный. Утечка в C-расширении — переходите на memray с
--leaks. tracemalloc туда не заглядывает. - memray падает или не запускается на старом Python. Проверьте требования к версии в документации пакета; на некоторых платформах и в контейнерах нужны заголовки для сборки. Как обходной путь — профилируйте в отдельном локальном окружении на том же коде.
- Объекты растут, но
show_backrefsне находит явного держателя. Смотрите в сторонуfunctools.lru_cacheбезmaxsize, глобальных реестров и модульного уровня — ссылки туда легко пропустить визуально. - RSS скачет, но задача одна и та же. Вероятно, фрагментация арен, а не течь. Вынесите обработку в дочерний процесс и сравните.
Что делать дальше
Повесьте экспорт RSS в метрики (Prometheus, statsd) — тренд по времени скажет больше, чем один снимок. Настройте алерт не на абсолютное значение, а на скорость роста: течь видно именно по наклону. Для воркеров задайте потолок по задачам как страховку, пока ищете причину. И держите tracemalloc выключенным в проде — включайте на стенде под воспроизводимой нагрузкой.
1 RSS (Resident Set Size) — объём физической оперативной памяти, который операционная система реально выделила процессу в данный момент.
2 Размер арены — деталь реализации pymalloc и может отличаться между версиями CPython; уточняйте в исходниках или документации конкретной версии.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: tracemalloc — Trace memory allocations, документация Python, memray — memory profiler for Python (GitHub, Bloomberg)
Частые вопросы
А если у меня Python 3.9 или 3.8, чек-лист подойдёт?
Помогает ли gc.collect() против утечки памяти?
Как отличить утечку от кеша без лимита?
Можно ли профилировать память прямо в проде?
Почему RSS не падает после того, как я удалил большой объект?
tracemalloc не показывает мою утечку в numpy, что делать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.