Утечки памяти в Python
Сервис не падает сразу. Память растёт неделю, потом OOM. Как это увидеть до ночи с пейджером: tracemalloc, циклы ссылок, кэш, который никто не чистит.
Table of Contents
Введение
Сборщик мусора есть. Это не страховка. Объект жив, пока на него кто-то ссылается: кэш без потолка, замыкание, цикл, глобальный список «на всякий случай». Неделю график памяти ползёт вверх. Потом OOM.
Ниже: как Python считает ссылки, где обычно течёт, чем смотреть в проде и как не останавливать сервис ради одного дампа.
Что здесь есть
- Как устроены reference counting и циклический GC
- Типичные дыры: кэш, циклы, C-расширения, «временные» глобалы
tracemalloc,memory_profiler,pympler,objgraph- Смотреть в проде, не выключая сервис
- Что поставить в мониторинг, чтобы увидеть рост до падения
Основы управления памятью в Python
Reference Counting + Garbage Collector
Python использует двухуровневую систему:
-
Reference counting — основной механизм. Каждый объект хранит счетчик ссылок (
ob_refcnt). Когда счетчик падает до нуля, память освобождается немедленно. -
Cyclic GC (generational garbage collector) — дополнительный механизм для обнаружения циклических ссылок, которые reference counting не может обработать.
import sys
import gc
# Reference counting
a = []
sys.getrefcount(a) # 2 (одна — переменная, одна — аргумент функции)
b = a
sys.getrefcount(a) # 3 (добавилась ссылка через b)
del b
sys.getrefcount(a) # 2 (ссылка удалена)
# Циклические ссылки — работа для GC
class Node:
def __init__(self):
self.ref = None
node1 = Node()
node2 = Node()
node1.ref = node2
node2.ref = node1 # Цикл!
del node1, node2 # Reference count не достигнет нуля
gc.collect() # GC обнаружит и освободит циклГенерации GC (поколения объектов)
GC делит объекты на три поколения:
- Generation 0: молодые объекты (проверяются часто)
- Generation 1: средние объекты (пережили одну сборку)
- Generation 2: старые объекты (проверяются редко)
import gc
# Настройки порогов сборки мусора
print(gc.get_threshold()) # (700, 10, 10)
# 700 — порог для gen0
# 10 — соотношение gen0/gen1 для сборки gen1
# 10 — соотношение gen1/gen2 для сборки gen2
# Статистика
print(gc.get_count()) # (581, 8, 2) — текущее количество объектовНа собеседовании могут спросить: "Почему в Python есть GC, если есть reference counting?" Ответ: reference counting не может обнаружить циклические ссылки (A → B → A). GC дополняет его, периодически сканируя объекты на циклы.
Типичные причины утечек памяти
1. Циклические ссылки с __del__
Если объект с __del__ участвует в цикле, GC не может его освободить (до Python 3.4 это была критическая проблема, в 3.4+ улучшено, но риск остается).
# ❌ Антипаттерн
class FileHandler:
def __init__(self, filename):
self.file = open(filename, 'w')
self.ref = None # Потенциальный цикл
def __del__(self):
self.file.close()
# Если создать цикл:
handler = FileHandler('test.txt')
handler.ref = handler # Цикл!
del handler # Утечка! GC не может вызвать __del__Решение: используйте context managers (with) вместо __del__:
# ✅ Правильно
class FileHandler:
def __init__(self, filename):
self.file = open(filename, 'w')
def __enter__(self):
return self
def __exit__(self, *args):
self.file.close()
with FileHandler('test.txt') as handler:
# Файл гарантированно закроется
pass2. Глобальные контейнеры (кеши, логи)
Самая распространенная причина утечек в продакшене.
# ❌ Утечка: неограниченный кеш
cache = {}
def get_user(user_id):
if user_id not in cache:
cache[user_id] = fetch_from_db(user_id) # Кеш растет вечно!
return cache[user_id]Решение: используйте functools.lru_cache или TTL-кеши:
# ✅ Правильно: LRU-кеш с ограничением
from functools import lru_cache
@lru_cache(maxsize=1000)
def get_user(user_id):
return fetch_from_db(user_id)
# ✅ Или TTL-кеш
from cachetools import TTLCache
cache = TTLCache(maxsize=1000, ttl=300) # 5 минут3. Замыкания с большими объектами
Функции-замыкания захватывают все переменные из внешнего scope, даже если не используют их.
# ❌ Утечка через замыкание
def create_handler():
large_data = [0] * 10_000_000 # 80 MB
small_value = 42
def handler():
return small_value # Захватывает и large_data!
return handler
callbacks = [create_handler() for _ in range(100)] # 8 GB утечка!Решение: минимизируйте scope или используйте weakref:
# ✅ Правильно
def create_handler():
large_data = [0] * 10_000_000
small_value = large_data[0] # Копируем только нужное
del large_data # Явно удаляем
def handler():
return small_value
return handler4. Не закрытые ресурсы (файлы, соединения)
# ❌ Утечка файловых дескрипторов
def process_files():
for filename in large_file_list:
f = open(filename) # Не закрыли!
data = f.read()
# f остается в памяти
# ✅ Правильно
def process_files():
for filename in large_file_list:
with open(filename) as f:
data = f.read()5. Слушатели событий и коллбеки
# ❌ Утечка через event listeners
class EventEmitter:
def __init__(self):
self.listeners = []
def on(self, callback):
self.listeners.append(callback) # Сильная ссылка!
emitter = EventEmitter()
class Handler:
def handle(self, event):
pass
handler = Handler()
emitter.on(handler.handle) # handler не может быть удален!
del handler # Утечка — ссылка в listenersРешение: используйте weakref.WeakMethod:
# ✅ Правильно
import weakref
class EventEmitter:
def __init__(self):
self.listeners = []
def on(self, callback):
weak_ref = weakref.WeakMethod(callback, self._cleanup)
self.listeners.append(weak_ref)
def _cleanup(self, ref):
self.listeners.remove(ref)
def emit(self, event):
for weak_callback in self.listeners[:]:
callback = weak_callback()
if callback is not None:
callback(event)Инструменты диагностики
1. tracemalloc (встроенный)
Самый простой способ начать профилирование.
import tracemalloc
# Старт трассировки
tracemalloc.start()
# Код с потенциальной утечкой
large_list = [i for i in range(1_000_000)]
# Снимок памяти
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
# Топ-10 источников аллокаций
for stat in top_stats[:10]:
print(stat)Вывод:
/path/to/script.py:5: size=38.1 MiB, count=1000000, average=40 B
/usr/lib/python3.11/collections/__init__.py:368: size=1.2 MiB, count=2500
Сравнение снимков (поиск утечек):
import tracemalloc
import time
tracemalloc.start()
# Снимок до
snapshot1 = tracemalloc.take_snapshot()
# Потенциально текущий код
leaked_data = []
for i in range(10):
leaked_data.append([0] * 1_000_000)
time.sleep(0.1)
# Снимок после
snapshot2 = tracemalloc.take_snapshot()
# Сравнение
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
for stat in top_stats[:5]:
print(stat)Вывод:
/path/to/script.py:12: size=76.3 MiB (+76.3 MiB), count=10 (+10)
2. memory_profiler
Построчный профилировщик памяти (аналог line_profiler).
Установка:
pip install memory-profilerИспользование:
from memory_profiler import profile
@profile
def my_function():
a = [1] * (10 ** 6)
b = [2] * (2 * 10 ** 7)
del b
return a
if __name__ == '__main__':
my_function()Запуск:
python -m memory_profiler script.pyВывод:
Line # Mem usage Increment Line Contents
================================================
3 38.8 MiB 38.8 MiB @profile
4 def my_function():
5 46.4 MiB 7.6 MiB a = [1] * (10 ** 6)
6 198.9 MiB 152.5 MiB b = [2] * (2 * 10 ** 7)
7 46.5 MiB -152.4 MiB del b
8 46.5 MiB 0.0 MiB return a
3. pympler (продвинутый анализ)
Библиотека для детального анализа объектов в памяти.
Установка:
pip install pymplerОтслеживание роста памяти:
from pympler import tracker
tr = tracker.SummaryTracker()
# Код
for i in range(3):
leaked = [0] * 1_000_000
tr.print_diff() # Показывает изменения между итерациямиАнализ размеров объектов:
from pympler import asizeof
my_list = [1, 2, 3, [4, 5, 6]]
print(asizeof.asizeof(my_list)) # Полный размер с учетом вложенных объектовПоиск утечек через muppy:
from pympler import muppy, summary
# Снимок всех объектов в памяти
all_objects = muppy.get_objects()
# Суммарная статистика
sum1 = summary.summarize(all_objects)
summary.print_(sum1)4. objgraph (визуализация ссылок)
Инструмент для поиска циклических ссылок и визуализации графа объектов.
Установка:
pip install objgraphПоиск утечек:
import objgraph
# Создание утечки
class Node:
def __init__(self):
self.ref = None
a = Node()
b = Node()
a.ref = b
b.ref = a
# Поиск циклов
objgraph.show_backrefs([a], filename='refs.png') # Граф ссылок
# Топ-10 типов объектов в памяти
objgraph.show_most_common_types(limit=10)Вывод:
tuple 15234
function 3421
dict 2841
list 1520
Отслеживание роста объектов:
import objgraph
objgraph.show_growth(limit=5)
# Код с утечкой
leaked = []
for i in range(1000):
leaked.append([0] * 1000)
objgraph.show_growth(limit=5) # Покажет прирост5. guppy3 / heapy
Мощный инструмент для анализа кучи (heap).
Установка:
pip install guppy3Использование:
from guppy import hpy
h = hpy()
print(h.heap())Вывод:
Partition of a set of 48477 objects. Total size = 3265516 bytes.
Index Count % Size % Cumulative % Kind (class / dict of class)
0 25773 53 1612820 49 1612820 49 str
1 11699 24 483960 15 2096780 64 tuple
2 174 0 241584 7 2338364 72 dict of module
Отладка в продакшене
Стратегия 1: Мониторинг метрик памяти
Prometheus + Grafana:
from prometheus_client import Gauge, start_http_server
import psutil
import os
# Метрики
memory_usage = Gauge('process_memory_bytes', 'Memory usage in bytes')
gc_collections = Gauge('gc_collections_total', 'GC collections', ['generation'])
def collect_metrics():
process = psutil.Process(os.getpid())
memory_usage.set(process.memory_info().rss)
import gc
stats = gc.get_stats()
for i, stat in enumerate(stats):
gc_collections.labels(generation=i).set(stat['collections'])
# Запуск метрик на порту 8000
start_http_server(8000)
# Периодический сбор
import threading
def monitor():
while True:
collect_metrics()
threading.Event().wait(10)
threading.Thread(target=monitor, daemon=True).start()Alert в Grafana:
# Рост памяти более 100 MB за 5 минут
rate(process_memory_bytes[5m]) > 100_000_000Стратегия 2: Периодический GC в долгоживущих процессах
import gc
import threading
import time
def periodic_gc():
while True:
time.sleep(300) # Каждые 5 минут
collected = gc.collect()
print(f"GC collected {collected} objects")
# Запуск в фоне
gc_thread = threading.Thread(target=periodic_gc, daemon=True)
gc_thread.start()Стратегия 3: Memory profiling в продакшене (осторожно!)
Использование tracemalloc с условным включением:
import os
import tracemalloc
if os.environ.get('MEMORY_PROFILING') == '1':
tracemalloc.start()
# Периодический дамп
def dump_snapshot():
snapshot = tracemalloc.take_snapshot()
with open(f'/tmp/memory_{time.time()}.dump', 'w') as f:
for stat in snapshot.statistics('lineno')[:50]:
f.write(f"{stat}\n")
# Вызов по расписанию или по сигналу
signal.signal(signal.SIGUSR1, lambda sig, frame: dump_snapshot())Активация в продакшене:
# Включить профилирование
export MEMORY_PROFILING=1
systemctl restart myapp
# Получить снимок
kill -USR1 <pid>
# Анализ
cat /tmp/memory_*.dumpСтратегия 4: Graceful restart при утечке
Защита от OOM через автоперезапуск:
import psutil
import os
import sys
MAX_MEMORY_MB = 1024 # 1 GB
def check_memory():
process = psutil.Process(os.getpid())
memory_mb = process.memory_info().rss / 1024 / 1024
if memory_mb > MAX_MEMORY_MB:
print(f"Memory limit exceeded: {memory_mb:.2f} MB. Restarting...")
# Graceful shutdown
cleanup()
os.execv(sys.executable, [sys.executable] + sys.argv)
# Проверка каждую минуту
import threading
def monitor():
while True:
check_memory()
threading.Event().wait(60)
threading.Thread(target=monitor, daemon=True).start()Стратегия 5: Core dump анализ
При критической утечке можно снять core dump для оффлайн-анализа.
Создание дампа:
# Установка gdb
apt-get install gdb
# Дамп процесса
gcore <pid>
# Анализ с помощью pyrasite (для живого процесса)
pip install pyrasite
pyrasite-shell <pid>В интерактивной оболочке:
import gc
import objgraph
objgraph.show_most_common_types(limit=20)
gc.collect()Вопросы на собеседовании
Junior уровень
Q: Что такое утечка памяти?
A: Ситуация, когда программа выделяет память, но не освобождает ее, даже если она больше не нужна. В Python это обычно происходит из-за сильных ссылок на объекты, которые должны были быть удалены.
Q: Как работает сборщик мусора в Python?
A: Python использует две системы:
- Reference counting — основной механизм, освобождает объекты сразу при достижении нуля ссылок
- Generational GC — дополнительный механизм для обнаружения циклических ссылок
Q: Что такое циклическая ссылка?
A: Ситуация, когда объекты ссылаются друг на друга (A → B → A), образуя цикл. Reference counting не может их освободить, нужен GC.
Middle уровень
Q: Почему в Python может быть утечка памяти, если есть GC?
A: GC не гарантирует освобождение памяти в следующих случаях:
- Глобальные переменные и контейнеры (кеши, логи)
- Замыкания, захватывающие большие объекты
- Объекты с
__del__в циклах (до Python 3.4) - Не закрытые ресурсы (файлы, соединения)
- C-расширения с ручным управлением памятью
Q: Как найти утечку памяти в Python?
A: Пошаговый подход:
- Подтвердить утечку (мониторинг RSS памяти)
- Использовать
tracemallocдля снимков до/после - Профилировать с
memory_profiler(построчно) - Анализировать объекты с
objgraph/pympler - Искать циклические ссылки с
gc.garbage
Q: Разница между RSS и heap size?
A:
- RSS (Resident Set Size) — физическая память процесса (включая shared libraries)
- Heap size — память, выделенная Python-аллокатором для объектов
RSS может расти из-за фрагментации, даже если heap size стабилен.
Senior/Expert уровень
Q: Как отладить утечку памяти в продакшене без остановки сервиса?
A: Многоуровневая стратегия:
- Мониторинг: Prometheus + Grafana для метрик памяти и GC
- Профилирование:
tracemallocс условным включением через env-переменную или сигнал - Sampling: периодические снимки памяти с записью в файлы
- Live debugging:
pyrasiteдля подключения к живому процессу - Graceful degradation: автоперезапуск при превышении лимита памяти
Q: Как оптимизировать GC для долгоживущих процессов?
A: Стратегии:
- Настройка порогов: увеличить пороги для gen2, чтобы редко сканировать старые объекты
gc.set_threshold(700, 10, 50) # Реже сканировать gen2 - Ручной GC: отключить автоматический GC и вызывать вручную в idle-периоды
gc.disable() # В idle-time gc.collect() - Freezing: перенести долгоживущие объекты в gen2 сразу
gc.freeze() # Все текущие объекты → permanent generation
Q: Как работает __del__ и почему он опасен?
A: __del__ — финализатор объекта, вызывается при удалении. Проблемы:
- Если объект в цикле с
__del__, GC не может его освободить (добавляется вgc.garbage) __del__вызывается в непредсказуемый момент- Исключения в
__del__игнорируются (только stderr)
Решение: используйте context managers (__enter__/__exit__) или weakref.finalize.
Q: Как диагностировать фрагментацию памяти?
A: Признаки:
- RSS растет, но heap size стабилен
tracemallocпоказывает стабильное потребление- Много аллокаций/деаллокаций небольших объектов
Решение:
- Использовать pymalloc арены: Python по умолчанию использует арены для объектов < 512 байт
- Мониторить arena usage:
import sys sys._debugmallocstats() # Детальная статистика pymalloc - Использовать jemalloc/tcmalloc: альтернативные аллокаторы с лучшей обработкой фрагментации
Q: Как обнаружить утечку в C-расширении?
A: Инструменты:
- Valgrind:
valgrind --leak-check=full python script.py - AddressSanitizer: пересобрать Python с ASAN
CFLAGS="-fsanitize=address" ./configure make - Guppy/heapy: может показать объекты, не управляемые Python GC
Чек-лист: профилактика утечек
В коде
- Используйте context managers для ресурсов (файлы, соединения)
- Ограничивайте размер кешей (
lru_cache,TTLCache) - Избегайте глобальных списков/словарей без очистки
- Используйте
weakrefдля коллбеков и слушателей - Не используйте
__del__(предпочитайтеweakref.finalize) - Минимизируйте scope в замыканиях
В архитектуре
- Worker pool с автоперезапуском (Celery, Gunicorn с max-requests)
- Мониторинг памяти (Prometheus, Datadog)
- Graceful shutdown при превышении лимита
- Логирование GC статистики
В DevOps
- Настроить alerts на рост памяти
- Ограничить память контейнеров (Docker memory limits)
- Использовать OOM killer как fallback
- Регулярные нагрузочные тесты с профилированием
Заключение
Утечки памяти в Python — это решаемая проблема при системном подходе:
- Понимание механизмов: reference counting + GC
- Раннее обнаружение: мониторинг и алерты
- Быстрая диагностика:
tracemalloc,memory_profiler,objgraph - Превентивные меры: ограничение кешей, context managers,
weakref - Защита в продакшене: graceful restart, limits, периодический GC
Ключевой навык для собеседования: уметь не только найти утечку, но и объяснить, почему она произошла и как предотвратить в будущем.
Дополнительные материалы
Официальная документация:
Инструменты:
Статьи: