PythonMiddleTechnical

Что такое циклические ссылки и как Python их собирает?

Циклические ссылки — когда объекты держат друг друга, а внешних ссылок нет. Refcount их не освободит; cyclic GC (модуль gc, generational, по поколениям 0/1/2) находит недостижимые группы и удаляет. Используйте weakref для parent-back-refs.

Что это

CPython управляет памятью двумя механизмами: reference counting (основной) и cyclic garbage collector (модуль gc) для случаев, которые refcount не вытянет. Циклическая ссылка возникает, когда объекты A и B держат друг друга (напрямую или через цепочку), и из программы на них уже нет ссылок. Refcount каждого больше нуля — refcount не освободит.

Как работает cyclic GC

  • GC отслеживает только container-объекты (списки, словари, классы, замыкания). Числа, строки, tuple из неконтейнеров не трекаются.
  • Generational: 3 поколения (0, 1, 2). Новые объекты идут в gen 0, выжившие — в старшие. Пороги: gc.get_threshold() == (700, 10, 10).
  • Алгоритм Колдер-Бэкона: GC временно вычитает «внутренние» ссылки внутри подозрительного подграфа и смотрит, остался ли у кого-нибудь refcount > 0. Если нет — группа недостижима, она освобождается.

Пример

import gc
import weakref


class Node:
    def __init__(self, name: str):
        self.name = name
        self.next: Node | None = None

    def __del__(self):
        print(f"del {self.name}")


# Создаём цикл: a -> b -> a
a = Node("A")
b = Node("B")
a.next = b
b.next = a

# refcount A и B по 2 (внешняя ссылка + ссылка из соседа)
# del убирает внешние, остаётся цикл
del a, b
print("collected:", gc.collect())  # collected: 2 + сообщения del A / del B


# Решение: weakref для обратной ссылки
class Parent:
    def __init__(self):
        self.children: list[Child] = []


class Child:
    def __init__(self, parent: Parent):
        self.parent = weakref.ref(parent)  # не удерживает parent


p = Parent()
c = Child(p)
p.children.append(c)
del p
# parent умирает сразу по refcount = 0, без gc.collect()
print(c.parent())  # None

Управление GC

  • gc.collect() — форсированный проход (можно по поколению).
  • gc.get_stats(), gc.get_objects(), gc.get_referrers(x) — диагностика.
  • gc.disable() в hot path и потом gc.enable() — иногда полезно (например, при массовой загрузке данных), но осторожно.
  • gc.set_debug(gc.DEBUG_LEAK) печатает несобираемые объекты.

Подводные камни

  • Двусторонние parent/child без weakref — типичный источник большого RSS у Django/SQLAlchemy сессий.
  • Замыкания в декораторах удерживают захваченные объекты, образуя циклы через self.
  • Раньше (до Python 3.4) __del__ у объектов в цикле приводил к gc.garbage — цикл не собирался. С PEP 442 это исправлено, но __del__ с побочными эффектами всё равно плохая идея.
  • Внешние ресурсы (файлы, сокеты, БД-соединения) лучше закрывать через with/contextlib.closing — не полагаться на GC.
  • Путать цикл и обычную утечку через глобальный список/кеш — GC не поможет, нужен weakref.WeakValueDictionary или явный evict.

Common mistakes

  • Не показывать пример A -> B -> A.
  • Говорить, что refcount решает все случаи.
  • Не знать про weakref как инструмент.

What the interviewer is testing

  • Объясняет недостижимость извне.
  • Понимает роль gc.
  • Отличает цикл от удерживаемой ссылки.

Sources

Related topics