Утечки памяти в Python: отладка и решение в продакшене
Экспертное руководство по обнаружению, диагностике и устранению утечек памяти в Python-приложениях. Готовимся к техническим собеседованиям и решаем реальные проблемы в продакшене.
Оглавление
Введение
Утечки памяти в Python — это одна из самых коварных проблем в продакшене. Несмотря на наличие автоматического сборщика мусора, Python-приложения могут незаметно "течь", пока не упадут с OOM (Out of Memory). В этом материале разберем механизмы утечек, инструменты диагностики и стратегии решения в боевых условиях.
Что вы узнаете:
- Как работает управление памятью в Python (reference counting + GC)
- Типичные причины утечек и антипаттерны
- Инструменты профилирования:
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
Это только начало материала
Полная версия материала доступна по паролю. Введите пароль, чтобы продолжить чтение.