Кастомная коллекция съела память: разбор инцидента на Python
Сервис на Python 3.11 начал падать по OOM через несколько часов работы. Причина — самописный класс-коллекция, который выглядел как обычный список, но вёл себя иначе. Разбираем, где протекло и как это ловить.

Фоновый обработчик на Python 3.11 держался в пределах 400 МБ первые часы, а к вечеру ядро убивало процесс по OOM (out of memory) — RSS уходил за 3 ГБ. Рестарт помогал ровно до следующего вечера. Утечка нашлась не в очевидном кэше и не в сторонней библиотеке, а в самописном классе EventBatch, который разработчик считал безобидной обёрткой над списком. Ниже — как коллекция утекла, почему профайлер сначала показывал не туда и что проверять в собственных коллекциях, чтобы не повторить.
Что происходило под нагрузкой
Обработчик читал события из очереди, складывал их в батчи и раз в несколько секунд сбрасывал в базу. Батч был реализован своим классом, который наследовался от list и добавлял пару удобных методов. Логика сброса очищала батч через присваивание нового пустого объекта. На бумаге старый батч должен был собраться сборщиком мусора сразу после сброса.
На практике память росла монотонно. tracemalloc показывал рост в модуле обработки, но конкретную строку не выделял — аллокации размазывались по десяткам мест. Первым подозреваемым стал драйвер БД, потом — логирование. Обе версии отпали после того, как обработчик прогнали на синтетических данных без базы и без логов: память всё равно текла.
Если объект живёт дольше, чем должен, ищите не того, кто его создал, а того, кто на него до сих пор ссылается. Утечки в Python — это почти всегда лишняя ссылка, а не забытый free.
Как локализовали
Помог модуль gc. После принудительного gc.collect() счётчик живых объектов EventBatch не падал — старые батчи не умирали. Дальше через gc.get_referrers() нашли, кто их держит: замыкание в колбэке, которое разработчик добавил для метрик и забыл. Каждый батч регистрировал колбэк с захватом self, колбэк складывался в список наблюдателей и никогда оттуда не удалялся.
Утечка была не в коллекции как таковой, а в том, что кастомный класс скрыл эту деталь за красивым интерфейсом. С обычным list никто бы не стал вешать на элемент колбэк — а тут метод класса делал это неявно.
# Python 3.11
# Упрощённая версия проблемного класса
class EventBatch(list):
def __init__(self, metrics_bus):
super().__init__()
self._metrics_bus = metrics_bus
# Вот тут утечка: колбэк с захватом self
# регистрируется навсегда и держит батч живым
metrics_bus.subscribe(self._report_size)
def _report_size(self):
return len(self)
class MetricsBus:
def __init__(self):
self._subscribers = [] # растёт бесконечно
def subscribe(self, fn):
self._subscribers.append(fn)
Каждый новый батч добавлял ссылку в _subscribers. Список подписчиков рос на каждый цикл сброса, и вместе с ним оставались живыми все батчи со всеми событиями внутри.
Почему сборщик мусора не спас
В CPython объект освобождается, когда его счётчик ссылок падает до нуля. Циклический сборщик (gc) добирает объекты, которые ссылаются друг на друга, но недостижимы извне. Здесь ни то, ни другое не срабатывало: на батч была живая, достижимая ссылка из списка _subscribers, который жил всё время работы процесса. Формально утечки в терминах C нет — это классическая логическая утечка: объект держится ссылкой, которая уже не нужна.
Отдельная ловушка — наследование от list. Разработчики часто ждут, что кастомный класс-коллекция ведёт себя как встроенный тип во всех мелочах. Это не так.
copy()и срезы у наследникаlistвозвращают базовыйlist, а не ваш класс, если вы не переопределили поведение. Логика, завязанная на тип, сломается тихо.- Методы вроде
__add__тоже возвращают базовый тип.batch + [x]даст обычный список. - Если вы храните тяжёлое состояние в атрибутах экземпляра (как
_metrics_busвыше), каждый экземпляр коллекции тащит это состояние в память вместе со всеми элементами.
Слоты и вес экземпляра
Второй фактор, усиливший инцидент: у класса не было __slots__, поэтому каждый экземпляр нёс собственный __dict__. Для тысяч живых батчей это дополнительные сотни байт на объект. Само по себе это не утечка, но при уже протёкшей логике добавило заметный объём. Для коллекций, которых создаётся много, разница между __dict__ и __slots__ видна в профиле памяти.
Как измерять, а не гадать
Точных цифр «до и после» для чужого железа приводить смысла нет — они зависят от объёма событий и частоты сброса. Важнее воспроизводимый способ мерить. Что использовали при разборе:
- tracemalloc — снимок аллокаций в двух точках и
snapshot.compare_to()для разницы. Даёт топ мест, где памяти прибавилось между снимками. - gc.get_objects() и подсчёт по типам — сколько живых экземпляров вашего класса после
gc.collect(). Если число не падает при простое — объекты кто-то держит. - gc.get_referrers(obj) — кто конкретно ссылается на подозрительный объект. Это и вывело на список подписчиков.
# Python 3.11
import gc
import tracemalloc
tracemalloc.start()
snapshot_before = tracemalloc.take_snapshot()
# ... прогон рабочего цикла ...
gc.collect()
snapshot_after = tracemalloc.take_snapshot()
# Топ мест, где прибавилось памяти
for stat in snapshot_after.compare_to(snapshot_before, 'lineno')[:10]:
print(stat)
# Сколько живых экземпляров конкретного класса
live = sum(1 for o in gc.get_objects()
if type(o).__name__ == 'EventBatch')
print('live EventBatch:', live)
Чем чинили
Радикально — убрали регистрацию колбэка из конструктора коллекции: метрики собирал сам обработчик снаружи, а не каждый батч изнутри. Для случаев, где связь наблюдателя действительно нужна, есть штатный инструмент.
| Приём | Что даёт | Цена |
|---|---|---|
| Убрать подписку из коллекции | Проблема исчезает в корне, объект живёт ровно сколько нужно | Метрики надо считать в другом месте |
weakref.ref / WeakSet для подписчиков | Подписчик не мешает сборке объекта | Колбэк исчезнет вместе с объектом — не всегда то, что нужно |
Явный unsubscribe при сбросе | Полный контроль над временем жизни | Легко забыть вызвать; нужен try/finally или контекстный менеджер |
Композиция вместо наследования list | Не тащит сюрпризы list, интерфейс явный | Придётся вручную пробросить нужные методы |
В итоге список наблюдателей перевели на weakref.WeakSet, чтобы регистрация подписчика не удерживала объект. После этого счётчик живых EventBatch после gc.collect() вернулся к числу активных батчей, а RSS перестал ползти вверх на многочасовом прогоне.
Где такой подход ломается
Слабые ссылки — не универсальное лекарство. Несколько граничных случаев, о которых стоит помнить:
- Слабые ссылки не на всё. Нельзя создать
weakrefна встроенныеlist,dict,tupleи на большинство базовых типов без обёртки. Ваш класс-наследникlistобычно поддерживает слабые ссылки, но проверяйте на своём типе. - Связанные методы недолговечны.
weakref.ref(obj.method)почти сразу станет мёртвой, потому что связанный метод создаётся на лету и тут же уничтожается. Для колбэков-методов используйтеweakref.WeakMethod. - gc.get_referrers дорогой. Он обходит все объекты и в проде на большой куче может подвесить процесс. Это инструмент отладки на стенде, а не в горячем пути.
- __slots__ ломает динамику. С ним нельзя добавить произвольный атрибут экземпляру и ломается наивное пиклирование в части случаев. Включайте осознанно, для массовых объектов.
Что мерить дальше
Инвалидация кэша и утечки в C-расширениях — отдельные темы, здесь только логическая утечка в чистом Python. После починки поставьте на процесс постоянный контроль: снимайте RSS и число живых объектов ключевых классов на интервале и заводьте алерт на монотонный рост за окно в несколько часов, а не на абсолютное значение. Разовый прогон под нагрузкой не ловит утечку, которая проявляется за часы, — нужен длинный прогон с периодическими снимками tracemalloc. И правило на ревью: любая регистрация колбэка или подписки требует явного ответа на вопрос «кто и когда её снимет».
Универсальный ассистент: Обычный чат: тексты, идеи, разбор, перевод, расчёты.
Спросить за 8 ₽Источники: Python docs — gc: Garbage Collector interface, Python docs — weakref: Weak references
Частые вопросы
У меня Python 3.10, а не 3.11 — приёмы подойдут?
Я перевёл подписчиков на WeakSet, а память всё равно течёт. Что не так?
Наследоваться от list вообще нельзя?
Насколько безопасно вызывать gc.get_referrers в продакшене?
Может, проще периодически перезапускать процесс, чем искать утечку?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.