Что такое циклические ссылки в Python и как с ними бороться

Ответ

Циклическая ссылка (reference cycle) — это ситуация в управлении памятью, когда два или более объекта ссылаются друг на друга, создавая замкнутый цикл сильных ссылок. Это мешает сборщику мусора освободить память, занимаемую этими объектами, что приводит к утечкам памяти.

Почему это проблема?

Стандартный механизм сборки мусора в Python (подсчёт ссылок) не может обработать циклы. Когда счётчик ссылок на объект падает до нуля, объект удаляется. В цикле счётчики ссылок объектов никогда не достигнут нуля, даже если на них больше нет ссылок извне цикла.

Хотя в Python есть дополнительный циклический сборщик мусора (cyclic GC), который находит и удаляет такие циклы, он работает периодически и не может разрешить циклы, если в них есть объекты с методом __del__.

Пример создания цикла:

class Node:
    def __init__(self, name):
        self.name = name
        self.parent = None
        print(f"Node {self.name} created")

    def __del__(self):
        # Этот метод усложняет работу циклического GC
        print(f"Node {self.name} destroyed")

# Создаем два объекта
a = Node('A')
b = Node('B')

# Создаем циклическую ссылку: a ссылается на b, а b на a
a.parent = b
b.parent = a

# Удаляем внешние ссылки на объекты
del a
del b

# Сообщение "destroyed" не будет выведено, объекты остались в памяти

Как бороться с циклическими ссылками?

Основной способ — использовать слабые ссылки с помощью модуля weakref. Слабая ссылка не увеличивает счётчик ссылок объекта, что позволяет разорвать цикл.

Решение с помощью weakref:

import weakref

class Node:
    def __init__(self, name):
        self.name = name
        # Храним слабую ссылку на родителя
        self._parent_ref = None
        print(f"Node {self.name} created")

    @property
    def parent(self):
        return self._parent_ref() if self._parent_ref else None

    @parent.setter
    def parent(self, parent_node):
        self._parent_ref = weakref.ref(parent_node) if parent_node else None

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

# Создаем объекты
a = Node('A')
b = Node('B')

# Создаем ссылки (одна из них слабая)
a.parent = b
b.parent = a # Эта ссылка будет слабой

del a
del b

# Теперь оба сообщения "destroyed" будут выведены, утечки памяти нет

Другой подход — явный разрыв цикла перед удалением объектов, например, вызвав b.parent = None.