Перейти к содержимому
pythonПродвинутый90 минут

Утечки памяти в Python

Сервис не падает сразу. Память растёт неделю, потом OOM. Как это увидеть до ночи с пейджером: tracemalloc, циклы ссылок, кэш, который никто не чистит.

#python#memory leaks#утечки памяти#garbage collector#profiling#tracemalloc#memory_profiler#pympler#objgraph#debugging#production#performance#собеседование#senior

Введение

Сборщик мусора есть. Это не страховка. Объект жив, пока на него кто-то ссылается: кэш без потолка, замыкание, цикл, глобальный список «на всякий случай». Неделю график памяти ползёт вверх. Потом OOM.

Ниже: как Python считает ссылки, где обычно течёт, чем смотреть в проде и как не останавливать сервис ради одного дампа.

Что здесь есть

  • Как устроены reference counting и циклический GC
  • Типичные дыры: кэш, циклы, C-расширения, «временные» глобалы
  • tracemalloc, memory_profiler, pympler, objgraph
  • Смотреть в проде, не выключая сервис
  • Что поставить в мониторинг, чтобы увидеть рост до падения

Основы управления памятью в Python

Reference Counting + Garbage Collector

Python использует двухуровневую систему:

  1. Reference counting — основной механизм. Каждый объект хранит счетчик ссылок (ob_refcnt). Когда счетчик падает до нуля, память освобождается немедленно.

  2. 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:
    # Файл гарантированно закроется
    pass

2. Глобальные контейнеры (кеши, логи)

Самая распространенная причина утечек в продакшене.

# ❌ Утечка: неограниченный кеш
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 handler

4. Не закрытые ресурсы (файлы, соединения)

# ❌ Утечка файловых дескрипторов
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 использует две системы:

  1. Reference counting — основной механизм, освобождает объекты сразу при достижении нуля ссылок
  2. Generational GC — дополнительный механизм для обнаружения циклических ссылок

Q: Что такое циклическая ссылка?

A: Ситуация, когда объекты ссылаются друг на друга (A → B → A), образуя цикл. Reference counting не может их освободить, нужен GC.

Middle уровень

Q: Почему в Python может быть утечка памяти, если есть GC?

A: GC не гарантирует освобождение памяти в следующих случаях:

  • Глобальные переменные и контейнеры (кеши, логи)
  • Замыкания, захватывающие большие объекты
  • Объекты с __del__ в циклах (до Python 3.4)
  • Не закрытые ресурсы (файлы, соединения)
  • C-расширения с ручным управлением памятью

Q: Как найти утечку памяти в Python?

A: Пошаговый подход:

  1. Подтвердить утечку (мониторинг RSS памяти)
  2. Использовать tracemalloc для снимков до/после
  3. Профилировать с memory_profiler (построчно)
  4. Анализировать объекты с objgraph / pympler
  5. Искать циклические ссылки с gc.garbage

Q: Разница между RSS и heap size?

A:

  • RSS (Resident Set Size) — физическая память процесса (включая shared libraries)
  • Heap size — память, выделенная Python-аллокатором для объектов

RSS может расти из-за фрагментации, даже если heap size стабилен.

Senior/Expert уровень

Q: Как отладить утечку памяти в продакшене без остановки сервиса?

A: Многоуровневая стратегия:

  1. Мониторинг: Prometheus + Grafana для метрик памяти и GC
  2. Профилирование: tracemalloc с условным включением через env-переменную или сигнал
  3. Sampling: периодические снимки памяти с записью в файлы
  4. Live debugging: pyrasite для подключения к живому процессу
  5. Graceful degradation: автоперезапуск при превышении лимита памяти

Q: Как оптимизировать GC для долгоживущих процессов?

A: Стратегии:

  1. Настройка порогов: увеличить пороги для gen2, чтобы редко сканировать старые объекты
    gc.set_threshold(700, 10, 50)  # Реже сканировать gen2
  2. Ручной GC: отключить автоматический GC и вызывать вручную в idle-периоды
    gc.disable()
    # В idle-time
    gc.collect()
  3. 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 показывает стабильное потребление
  • Много аллокаций/деаллокаций небольших объектов

Решение:

  1. Использовать pymalloc арены: Python по умолчанию использует арены для объектов < 512 байт
  2. Мониторить arena usage:
    import sys
    sys._debugmallocstats()  # Детальная статистика pymalloc
  3. Использовать jemalloc/tcmalloc: альтернативные аллокаторы с лучшей обработкой фрагментации

Q: Как обнаружить утечку в C-расширении?

A: Инструменты:

  1. Valgrind: valgrind --leak-check=full python script.py
  2. AddressSanitizer: пересобрать Python с ASAN
    CFLAGS="-fsanitize=address" ./configure
    make
  3. 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 — это решаемая проблема при системном подходе:

  1. Понимание механизмов: reference counting + GC
  2. Раннее обнаружение: мониторинг и алерты
  3. Быстрая диагностика: tracemalloc, memory_profiler, objgraph
  4. Превентивные меры: ограничение кешей, context managers, weakref
  5. Защита в продакшене: graceful restart, limits, периодический GC

Ключевой навык для собеседования: уметь не только найти утечку, но и объяснить, почему она произошла и как предотвратить в будущем.

Дополнительные материалы

Официальная документация:

Инструменты:

Статьи: