Skip to main content
pythonAdvanced90 минут

Утечки памяти в Python: отладка и решение в продакшене

Экспертное руководство по обнаружению, диагностике и устранению утечек памяти в Python-приложениях. Готовимся к техническим собеседованиям и решаем реальные проблемы в продакшене.

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

Введение

Утечки памяти в Python — это одна из самых коварных проблем в продакшене. Несмотря на наличие автоматического сборщика мусора, Python-приложения могут незаметно "течь", пока не упадут с OOM (Out of Memory). В этом материале разберем механизмы утечек, инструменты диагностики и стратегии решения в боевых условиях.

Что вы узнаете:

  • Как работает управление памятью в Python (reference counting + GC)
  • Типичные причины утечек и антипаттерны
  • Инструменты профилирования: 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

This is just the beginning

Full material is password-protected. Enter the password to continue reading.

Утечки памяти в Python: отладка и решение в продакшене — Learning Center — Potapov.me