Как устроен сборщик мусора в CPython: трассировка объектов и циклические ссылки
Погружаемся в механизм reference counting и cyclic garbage collector — смотрим на исходный код CPython, пишем тесты и выясняем, когда объект действительно удаляется из памяти.
Содержание
Как устроен сборщик мусора в CPython: трассировка объектов и циклические ссылки
Большинство Python-разработчиков знают, что в языке есть автоматическое управление памятью, и этого им достаточно. Объект создали — Python сам разберётся, когда его удалить. Но стоит начать писать долгоживущие сервисы, работать с большими графами объектов или отлаживать утечки памяти — и это знание перестаёт быть достаточным. Нужно понимать, как механизм работает изнутри.
CPython использует двухуровневую систему управления памятью: основной механизм — подсчёт ссылок (reference counting), вспомогательный — циклический сборщик мусора (cyclic garbage collector). Оба работают одновременно, но решают разные задачи. Разберём каждый из них, заглянем в исходный код и напишем тесты, которые покажут реальное поведение интерпретатора.
Reference Counting: основа основ
Как это работает
Каждый объект в CPython хранит счётчик ссылок — поле ob_refcnt в структуре PyObject. Когда на объект появляется новая ссылка, счётчик увеличивается; когда ссылка исчезает — уменьшается. Как только счётчик достигает нуля, объект немедленно уничтожается.
Вот упрощённое определение PyObject из заголовочного файла Include/object.h:
typedef struct _object {
Py_ssize_t ob_refcnt; // счётчик ссылок
PyTypeObject *ob_type; // указатель на тип объекта
} PyObject;
Макросы Py_INCREF и Py_DECREF управляют счётчиком:
#define Py_INCREF(op) (((PyObject *)(op))->ob_refcnt++)
#define Py_DECREF(op) \
do { \
PyObject *_py_decref_tmp = (PyObject *)(op); \
if (--(_py_decref_tmp->ob_refcnt) == 0) { \
_Py_Dealloc(_py_decref_tmp); \
} \
} while (0)
Когда ob_refcnt падает до нуля, вызывается _Py_Dealloc, который запускает деструктор типа (tp_dealloc). Для большинства объектов это означает освобождение памяти прямо здесь и сейчас — без каких-либо пауз и без участия циклического GC.
Проверяем счётчик ссылок из Python
Модуль sys предоставляет функцию getrefcount, которая возвращает текущее значение счётчика. Важный нюанс: при вызове функции создаётся дополнительная ссылка на объект (аргумент функции), поэтому результат всегда на единицу больше «реального» значения.
import sys
x = []
print(sys.getrefcount(x)) # 2: одна ссылка x, одна — аргумент функции
y = x
print(sys.getrefcount(x)) # 3: x, y и аргумент
del y
print(sys.getrefcount(x)) # снова 2
Посмотрим, когда именно вызывается __del__:
import sys
class Tracked:
def __init__(self, name):
self.name = name
print(f"[{self.name}] создан, refcount={sys.getrefcount(self)}")
def __del__(self):
print(f"[{self.name}] уничтожен")
obj = Tracked("A")
print(f"refcount после создания: {sys.getrefcount(obj)}")
ref1 = obj
ref2 = obj
print(f"refcount с двумя алиасами: {sys.getrefcount(obj)}")
del ref1
del ref2
print("ref1 и ref2 удалены, obj ещё жив")
del obj
print("после del obj")
Вывод будет предсказуемым:
[A] создан, refcount=2
refcount после создания: 2
refcount с двумя алиасами: 4
ref1 и ref2 удалены, obj ещё жив
[A] уничтожен
после del obj
Объект уничтожается ровно в момент del obj — не позже, не раньше. Это детерминированное поведение, которое отличает CPython от других реализаций Python (PyPy, Jython), где reference counting не используется.
Когда счётчик не помогает
Reference counting элегантен, но у него есть фундаментальный изъян: он не умеет обнаруживать циклические ссылки. Если объект A ссылается на объект B, а B ссылается обратно на A, счётчик ни одного из них никогда не достигнет нуля — даже если из остального кода на эти объекты нет ни одной ссылки.
import gc
gc.disable() # отключим циклический GC, чтобы увидеть проблему чисто
class Node:
def __init__(self, name):
self.name = name
self.ref = None
def __del__(self):
print(f"Node({self.name}) уничтожен")
a = Node("A")
b = Node("B")
a.ref = b # A → B
b.ref = a # B → A (цикл!)
del a
del b
print("После del a и del b — деструкторы не вызваны")
# Объекты живы, хотя достичь их из кода уже невозможно
После del a и del b счётчики обоих объектов равны 1 (каждый держит ссылку на другого), но из пользовательского кода они недостижимы. Это классическая утечка памяти в CPython без циклического GC.
Cyclic Garbage Collector: охота на циклы
Архитектура: три поколения
Циклический GC в CPython реализован в файле Modules/gcmodule.c. Он использует алгоритм на основе трассировки достижимости, но применяет его не ко всем объектам подряд, а только к тем, которые могут участвовать в циклах — то есть к контейнерным объектам (списки, словари, экземпляры классов и т.д.).
GC делит объекты на три поколения (generation 0, 1, 2):
- Поколение 0 — только что созданные объекты. Собирается чаще всего.
- Поколение 1 — объекты, пережившие одну сборку поколения 0.
- Поколение 2 — долгоживущие объекты. Собирается реже всего.
Пороги срабатывания можно посмотреть и изменить:
import gc
print(gc.get_threshold()) # (700, 10, 10) по умолчанию
# Поколение 0 собирается, когда число новых объектов превышает 700
# Поколение 1 — каждые 10 сборок поколения 0
# Поколение 2 — каждые 10 сборок поколения 1
Алгоритм: как находят мусор
Алгоритм циклического GC основан на идее «временного уменьшения счётчиков». Вот его суть:
- GC берёт все объекты из текущего поколения.
- Для каждого объекта он проходит по его внутренним ссылкам и уменьшает
ob_refcntу каждого объекта, на который есть ссылка (это делается черезtp_traverse). - После этого объекты с
ob_refcnt > 0— это те, на которые есть ссылки извне поколения. Они помечаются как достижимые. - Все объекты, достижимые из «живых», тоже помечаются как живые (транзитивно).
- Оставшиеся объекты с
ob_refcnt == 0— мусор. Их можно удалить.
Именно поэтому GC требует, чтобы типы, которые могут содержать ссылки на другие объекты, реализовывали tp_traverse и tp_clear. Без этого GC не сможет обойти граф ссылок.
Смотрим на работу GC в действии
import gc
import sys
gc.disable() # управляем GC вручную
class Tracked:
def __init__(self, name):
self.name = name
self.other = None
def __del__(self):
print(f" → {self.name} уничтожен")
# Создаём цикл
a = Tracked("A")
b = Tracked("B")
a.other = b
b.other = a
# Убираем внешние ссылки
del a
del b
print(f"Объектов в поколении 0: {len(gc.get_objects(generation=0))}")
print("Запускаем GC вручную...")
collected = gc.collect(generation=0)
print(f"Собрано объектов: {collected}")
Вывод:
Объектов в поколении 0: <N>
Запускаем GC вручную...
→ A уничтожен
→ B уничтожен
Собрано объектов: 4
Число 4 — это два объекта плюс два словаря __dict__ (у каждого экземпляра есть свой __dict__, который тоже участвует в цикле через атрибут other).
Отслеживание объектов через gc.get_referrers
Модуль gc позволяет исследовать граф ссылок напрямую:
import gc
class Container:
pass
obj = Container()
lst = [obj]
dct = {"key": obj}
referrers = gc.get_referrers(obj)
for r in referrers:
print(type(r), repr(r)[:80])
Это полезно при отладке утечек: можно найти, кто именно держит ссылку на объект, который должен был быть удалён.
Подводные камни и типичные ошибки
del и циклические ссылки
До Python 3.4 объекты с __del__, участвующие в циклах, не собирались GC вообще — они попадали в gc.garbage (список недостижимых, но неудалённых объектов). Начиная с Python 3.4, благодаря PEP 442, это ограничение снято: GC научился безопасно вызывать __del__ для объектов в циклах.
Тем не менее, __del__ остаётся источником проблем:
import gc
class Problematic:
instances = [] # ссылка на уровне класса
def __init__(self, name):
self.name = name
Problematic.instances.append(self) # объект держит ссылку на себя через класс!
def __del__(self):
print(f"{self.name} удалён")
p = Problematic("test")
del p
# Объект НЕ будет удалён, пока Problematic.instances содержит на него ссылку
print(f"Живых экземпляров: {len(Problematic.instances)}")
Это не баг — это ожидаемое поведение. Но именно такие паттерны приводят к утечкам памяти в продакшн-коде.
weakref как инструмент разрыва циклов
Стандартный способ разорвать нежелательный цикл — использовать слабые ссылки (weakref). Слабая ссылка не увеличивает счётчик ссылок:
import weakref
import gc
class Node:
def __init__(self, name):
self.name = name
self.parent = None
def __del__(self):
print(f"Node({self.name}) удалён")
parent = Node("parent")
child = Node("child")
# Используем weakref для обратной ссылки
child.parent = weakref.ref(parent)
del parent
# parent удаляется немедленно, потому что weakref не держит счётчик
print("parent удалён")
# Попытка обратиться через weakref после удаления вернёт None
print(child.parent()) # None
Этот паттерн особенно важен при реализации двусвязных структур данных, кэшей и обратных ссылок в ORM-подобных системах.
GC и производительность
Циклический GC — не бесплатная операция. При каждом запуске он приостанавливает выполнение программы (stop-the-world). Для большинства приложений это незаметно, но в высоконагруженных сервисах с большим числом живых объектов это может стать проблемой.
Практика Instagram Engineering (они публично описывали это в своих технических блогах) — отключать GC на воркерах после форка, когда известно, что циклов нет. Это даёт заметный прирост производительности за счёт снижения накладных расходов.
import gc
# Отключить автоматический GC (только если вы уверены в отсутствии циклов)
gc.disable()
# Или настроить пороги более агрессивно для долгоживущих объектов
gc.set_threshold(1000, 15, 15)
Для тех, кто хочет разобраться в этих тонкостях системно — как в архитектуре CPython, так и в написании производительного Python-кода — есть смысл посмотреть на программу курса «Senior Python»: там эти темы рассматриваются в контексте реальных задач оптимизации и архитектурных решений.
Практика: пишем детектор утечек
Соберём простой инструмент, который показывает, какие объекты остаются в памяти после выполнения блока кода:
import gc
import tracemalloc
from collections import Counter
class LeakDetector:
def __enter__(self):
gc.collect()
self.snapshot_before = self._get_object_counts()
return self
def __exit__(self, *args):
gc.collect()
snapshot_after = self._get_object_counts()
self.diff = {
k: snapshot_after.get(k, 0) - self.snapshot_before.get(k, 0)
for k in set(snapshot_after) | set(self.snapshot_before)
if snapshot_after.get(k, 0) != self.snapshot_before.get(k, 0)
}
def _get_object_counts(self):
return Counter(type(obj).__name__ for obj in gc.get_objects())
def report(self):
leaks = {k: v for k, v in self.diff.items() if v > 0}
if not leaks:
print("Утечек не обнаружено")
else:
print("Возможные утечки:")
for typename, count in sorted(leaks.items(), key=lambda x: -x[1]):
print(f" {typename}: +{count}")
# Пример использования
class MyObject:
pass
with LeakDetector() as detector:
leaked = []
for _ in range(100):
obj = MyObject()
leaked.append(obj) # намеренно держим ссылки
# leaked выходит из области видимости... но нет, он создан снаружи блока with
detector.report()
Это грубый инструмент, но он наглядно показывает принцип: сравниваем снимки живых объектов до и после. Для продакшн-отладки существуют более мощные решения — tracemalloc, memory_profiler, objgraph.
Итоги
CPython управляет памятью через два взаимодополняющих механизма. Reference counting — быстрый, детерминированный, работает для большинства случаев. Циклический GC — медленнее, запускается периодически, но умеет то, чего не умеет reference counting: находить и удалять объекты, связанные циклами.
Ключевые выводы для практики:
- Объект удаляется немедленно, когда его счётчик ссылок падает до нуля. Это предсказуемо и детерминировано.
- Циклические ссылки не удаляются reference counting — за них отвечает GC.
__del__с Python 3.4 работает корректно даже в циклах, но всё равно остаётся источником неочевидных проблем.weakref— правильный инструмент для обратных ссылок в двусвязных структурах.- GC можно и нужно настраивать в высоконагруженных приложениях.
Понимание этих механизмов — не академическое упражнение. Это то, что отличает разработчика, который «пишет код», от того, кто понимает, как этот код живёт в памяти. Если хочется систематически закрыть этот пробел, курс «Senior Python» — один из способов сделать это структурированно, с погружением в CPython и смежные темы.
Комментарии
Пока нет комментариев