Модель памяти CPython: подсчёт ссылок и сборщик циклов
Почему объект в CPython не всегда освобождается сразу после del, как подсчёт ссылок уживается с циклическим сборщиком и где эта связка ломается под нагрузкой.

Что происходит, когда вы делаете del obj
Вы удалили ссылку на объект, а память процесса не уменьшилась. Или наоборот: сервис на несколько минут замирает раз в час, и профилировщик показывает время внутри gc.collect(). Оба симптома — про одно и то же: у CPython два независимых механизма освобождения памяти, и они работают по разным правилам. Разберём, кто из них когда срабатывает и почему объект переживает del.
Речь про CPython 3.11–3.13 — референсную реализацию с сайта python.org. В PyPy и других реализациях модель памяти другая, подсчёта ссылок там может не быть вовсе.
Основной механизм: подсчёт ссылок
Каждый объект в CPython несёт счётчик — сколько имён, элементов контейнеров и полей сейчас на него указывают. Поле хранится в заголовке объекта (структура PyObject, поле ob_refcnt). Когда счётчик доходит до нуля, объект освобождается немедленно и синхронно — прямо в той строке, где исчезла последняя ссылка.
Посмотреть счётчик можно через sys.getrefcount. Важная деталь: сам вызов создаёт временную ссылку через аргумент функции, поэтому значение всегда на единицу больше ожидаемого.
import sys
data = [1, 2, 3]
another = data # вторая ссылка на тот же список
# два имени + временная ссылка от аргумента getrefcount = 3
print(sys.getrefcount(data)) # 3
del another # осталось одно имя
print(sys.getrefcount(data)) # 2Пока счётчик держится выше нуля, память занята. Отсюда первое практическое следствие: del x не освобождает объект, а всего лишь убирает одно имя. Если на объект ссылается что-то ещё — кеш, замыкание, элемент списка — он живёт дальше.
Подсчёт ссылок освобождает память детерминированно и без пауз. Именно поэтому в CPython файл, открытый в цикле без with, часто закрывается вовремя — но полагаться на это нельзя, в других реализациях этого поведения нет.Плата за детерминизм
За мгновенное освобождение платят двумя вещами. Первая — операции Py_INCREF/Py_DECREF происходят постоянно, на каждой передаче объекта, и это накладные расходы на уровне интерпретатора. Вторая — счётчик мешает распараллеливанию: пока в 3.13 GIL остаётся включённым по умолчанию, изменение счётчиков не требует блокировок. Экспериментальная сборка без GIL (free-threaded, PEP 703) как раз упирается в то, что подсчёт ссылок надо делать потокобезопасным.
Проблема, которую подсчёт ссылок не решает
Подсчёт ссылок не умеет освобождать циклы. Классический случай:
class Node:
def __init__(self):
self.parent = None
self.child = None
parent = Node()
child = Node()
parent.child = child # parent ссылается на child
child.parent = parent # child ссылается на parent
del parent
del child
# оба имени удалены, но объекты ссылаются друг на друга —
# счётчик каждого равен 1, а не 0. Память не освобождена.После двух del на объекты не указывает ни одно имя из вашего кода. Но parent.child держит один объект, child.parent — другой. Счётчик каждого равен единице. С точки зрения подсчёта ссылок оба живы, хотя из программы до них уже не добраться. Это утечка, и одним подсчётом ссылок её не закрыть.
Второй механизм: циклический сборщик
Для таких случаев в CPython есть отдельный сборщик мусора — модуль gc. Он ищет именно циклы среди объектов, которые могут содержать ссылки друг на друга: контейнеры, экземпляры классов, замыкания. Простые объекты вроде чисел и строк сборщик не отслеживает — они не способны образовать цикл, их полностью обслуживает подсчёт ссылок.
Работает он поколениями. Отслеживаемые объекты разбиты на три поколения (0, 1, 2). Новые попадают в поколение 0. Сборщик исходит из гипотезы: большинство объектов умирает молодыми, поэтому молодое поколение проверяется часто, старшие — реже. Пережившие сборку объекты переводятся в следующее поколение.
Запуск сборки поколения 0 привязан не к времени, а к разнице между числом выделенных и освобождённых отслеживаемых объектов. Пороги видны и настраиваются через gc.get_threshold() и gc.set_threshold(). Точные значения по умолчанию и детали алгоритма уточняйте в документации модуля gc для вашей версии — между 3.11 и 3.12 внутренности сборщика менялись.
Как сборщик находит мусор
Схематично алгоритм для одного поколения такой:
- Сборщик берёт объекты поколения и делает временную копию их счётчиков ссылок.
- Проходит по всем ссылкам внутри группы и вычитает из копий те ссылки, что идут от объектов той же группы.
- Объект, у которого после вычитания копия счётчика больше нуля, достижим извне группы — он живой, и всё, до чего он дотягивается, тоже.
- Оставшиеся объекты с нулём — недостижимый цикл, их можно освобождать.
Именно на шагах 2–3 сборщик обходит граф объектов, и чем больше живых объектов в старшем поколении, тем дольше пауза.
Где это ломается под нагрузкой
Проблема не в самих циклах, а в объёме долгоживущих объектов. Типичный сценарий: сервис держит большой кеш или граф в памяти — сотни тысяч отслеживаемых объектов, которые не умирают. Каждая сборка поколения 2 обходит их все, находит, что все живы, и ничего не освобождает. Но время на обход тратит, и это выливается в периодические паузы, синхронные к запросам.
Второй граничный случай — объекты с методом __del__, участвующие в цикле. До Python 3.4 (PEP 442 изменил это) сборщик не мог безопасно определить порядок вызова финализаторов в цикле и складывал такие объекты в gc.garbage, откуда их приходилось разгребать вручную. В современных версиях финализаторы в циклах вызываются, но порядок между ними не гарантирован — не пишите __del__, который зависит от того, что связанный объект ещё жив.
Что мерить
Прежде чем крутить настройки, соберите данные:
gc.get_count()— текущие счётчики поколений, показывают, насколько близко к порогу сборки.gc.get_stats()— статистика по поколениям: сколько сборок было и сколько объектов собрано. Если в поколении 2 собирается почти ноль объектов при регулярных запусках — сборщик работает вхолостую.- Замер времени внутри
gc.collect()под реальной нагрузкой и объёмом данных, а не на пустом процессе. Цифра из микробенчмарка тут бесполезна. gc.callbacks— хук на начало и конец сборки, чтобы залогировать паузы в проде.
Стратегии для сервисов с большим кучей объектов
| Подход | Что выигрываете | Чем платите |
|---|---|---|
| Оставить дефолт | Ничего не настраивать, циклы собираются автоматически | Периодические паузы на обход старшего поколения |
gc.disable() на время критичной секции | Нет пауз в горячем пути | Циклы копятся, нужно вручную вызвать сборку в безопасный момент |
Повысить пороги set_threshold | Реже сборки, меньше суммарных пауз | Пиковое потребление памяти растёт между сборками |
«Заморозить» старые объекты gc.freeze() | Долгоживущие объекты исключаются из обходов | Работает как одноразовая мера после загрузки данных, не для растущего графа |
Метод gc.freeze() появился в Python 3.7 и хорошо ложится на паттерн «загрузили большой неизменяемый набор данных при старте — заморозили — дальше он не мешает сборщику». Полезен и перед fork: замороженные объекты не будут копироваться при обращении copy-on-write.
Как избегать циклов вместо борьбы с ними
Дешевле не создавать циклы, чем оптимизировать их сборку. Для ссылок «назад» — от ребёнка к родителю, от подписчика к источнику — используйте weakref. Слабая ссылка не увеличивает счётчик и не образует цикл, объект освобождается подсчётом ссылок сразу.
import weakref
class Node:
def __init__(self):
self.child = None
self._parent = None
@property
def parent(self):
# weakref.ref() возвращается вызовом: вернёт объект или None
return self._parent() if self._parent else None
@parent.setter
def parent(self, node):
# слабая ссылка не держит объект в памяти
self._parent = weakref.ref(node) if node else None
parent = Node()
child = Node()
parent.child = child
child.parent = parent # слабая ссылка, цикла нет
del parent
del child
# оба объекта освобождаются сразу подсчётом ссылок,
# сборщик циклов не нуженЧто делать дальше
Начните с замера, а не с настроек: включите gc.callbacks и посмотрите, есть ли вообще паузы в вашем сервисе и совпадают ли они с деградацией. Если gc.get_stats() показывает, что старшее поколение собирается вхолостую при большом графе — примерьте gc.freeze() после инициализации. Ссылки «назад» переводите на weakref на этапе проектирования модели данных. И держите под рукой документацию модуля gc для вашей версии: пороги, поколения и поведение финализаторов между релизами меняются.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Python documentation — gc: Garbage Collector interface, Python documentation — weakref: Weak references
Частые вопросы
У меня Python 3.9, а статья про 3.11–3.13. Подойдёт?
Почему после del память не вернулась операционной системе?
Стоит ли вызывать gc.collect() вручную?
Если отключить сборщик gc.disable(), будет утечка?
Как понять, что проблема именно в циклическом сборщике, а не в обычной утечке?
Замедляет ли weakref работу программы?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.