На Хабре появилась толковая статья о неприятностях, которые может доставить Garbage Collector.
Отмечу актуальность описания проблемы и полную корректность решения для tornado.
Однако не могу не обратить внимание на слабое освещение того, как именно работает сборщик мусора в CPython.
Почему при работающем garbage collector появляется так много неудаляемых объектов?
Немного теории.
Сборщик мусора имеет три поколения (счёт начинается с нуля). При создании объекта он попадает в нулевое поколение.
У каждого поколения есть счётчик и порог. Работает эта пара так:
- При добавлении объекта в поколение счётчик увеличивается.
- При выбывании из поколения счётчик уменьшается.
- Когда счётчик превысит пороговое значение — по всем объектам из поколения пройдётся сборщик мусора. Кого найдёт — удалит.
- Все выжившие в поколении объекты перемещаются в следующее (из нулевого в первое, из первого во второе). Из второго поколения объекты никуда не попадают и остаются там навечно.
- Перемещённые в следующее поколение объекты меняют соответствующий счетчик, и операция может повториться уже для следующего поколения.
- Счётчик текущего поколения сбрасывается.
Объекты, подлежащие уничтожению но имеющие переопределённый метод
__del__
не могут быть собраны. Причина проста: эти объекты могут
ссылаться друг на друга.
Python не способен определить безопасный порядок вызова __del__
. Если
вызывать деструкторы в произвольном порядке, то можно получить
ситуацию вида:
- Деструктор объекта a для работы требует объект b.
- Последний в своём деструкторе обращается к объекту a.
- Если вызовем
__del__
у a, то деструктор b не сможет отработать нормально. Ссылка на a будет иметь значение None.
Чтобы не заставлять программиста корректно разрешать такие ситуации
было принято решение не уничтожать подобные объекты а просто
перемещать их в gc.garbage
— и дальше программист пусть сам
разбирается что делать с этим мусором.
К слову, в потоках-демонах тоже возникает ситуация подобная описанной выше, но там программист должен быть готов к тому что переменная внезапно стала None. Подробности смотрите здесь
Перейдём к практической части.
Пример
Для иллюстрации рассмотрим немного изменённый пример из приведенной в самом начале статьи.
Имеем классическую древовидную структуру:
class Node(object):
parent = None
def __init__(self, *children):
self.children = list(children)
for node in self.children:
node.parent = self
@classmethod
def tree(cls, depth=1, numchildren=1):
if depth == 0:
return []
return [cls(*cls.tree(depth-1, numchildren))
for _ in range(numchildren)]
Родитель и потомки напрямую связаны друг с другом циклической связью.
Метод tree
создает дерево нужной глубины.
Добавляем garbage collection hook для того чтобы увидеть когда срабатывает сборщик мусора и сколько объектов он уничтожает:
import gc
def gc_cb(phase, info):
if not info['collected'] and not info['uncollectable']:
return
print("{0}:\t{1[generation]}\t{1[collected]}\t{1[uncollectable]}".format(
phase, info))
gc.callbacks.append(gc_cb)
Наконец, делаем много-много наших деревьев и смотрим как они разрушаются:
for n in range(20):
for _ in range(n):
Node.tree(depth=5, numchildren=6)
Пороги стоят стандартные:
>>> gc.get_threshold()
(700, 10, 10)
700 объектов в нулевом поколении и по 10 в первом и во втором.
Анализ
Теперь о том, почему образуется столько мусора.
Пример напечатает что-то вроде такого (вырезка из очень длинного результата):
...
stop: 1, 4665, 0
stop: 2, 79305, 0
stop: 1, 4665, 0
stop: 2, 79305, 0
stop: 1, 4665, 0
stop: 1, 4665, 0
stop: 1, 4665, 0
stop: 2, 97965, 0
stop: 1, 4665, 0
stop: 2, 79305, 0
stop: 1, 4665, 0
...
За один вызов Node.tree(depth=5, numchildren=6)
создается 9330 тесно
связанных объектов, которые нельзя разрушить в 0 поколении (помним,
что порог 700). Значит они попадают в первое, а большая часть даже во
второе поколение (9330>700*10). Наконец все 9330 объекта созданы,
можно разрушать.
На уменьшении счётчиков ссылок на объекты ничего убрать не получится. Поэтому ждём, когда опять превысим порог в 700 (на следующем вызове Node.tree, конечно).
Собираем нулевое поколение (оно оказывается заполнено свежими данными и поживиться почти ничем не удаётся).
А сборщик мусора для поколения 1 вызовется только если туда попадут как минимум 10 объектов из поколения 0.
Хорошо, мы добрались до сбора в 1 поколении. Часть циклов можно уничтожить сразу (два поколения для анализа лучше одного), некоторые переправляются в поколение 2. В котором сборщик запускается тоже если в свою очередь превысили порог.
Что случается ещё реже и таким образом наши объекты накапливаются во втором поколении. Когда сборщик мусора доходит до него, то всё чистит.
Проблема в том, что до последнего поколения дело доходит относительно редко.
В результате имеем не слишком типичный для сборщика мусора случай.
Чиним
Конечно, лучше всего не доводить дело до сборщика мусора вообще в случаях подобным нашему синтетическому примеру.
Разрушать ссылки вручную через вызов del
или присваивания None
очень неудобно, но есть и другой способ.
Воспользуемся слабыми ссылками на родителя:
import weakref
class Node(object):
parent = None
def __init__(self, *children):
self.children = list(children)
for node in self.children:
node.parent = weakref.proxy(self)
Я предпочитаю weakref.ref
как дающий больший контроль (всегда можно
добавить свойство):
class Node(object):
_parent = None
def __init__(self, *children):
self.children = list(children)
for node in self.children:
node._parent = weakref.ref(self)
@property
def parent(self):
if self._parent is None:
return None
else:
return self._parent()
В любом варианте дело до сборщика мусора не дойдёт и объекты будут уничтожены сразу как только перестанут быть нужны.
Если вариант со слабыми ссылками почему-то не проходит можно просто увеличить пороги. У нас создаётся за раз 9330 объектов? Поставим порог для первого поколения в 10000.
gc.set_threshold(10000, 100, 100)
Результат выглядит куда лучше:
...
stop: 0, 4665, 0
stop: 0, 4665, 0
stop: 0, 4665, 0
stop: 0, 4665, 0
stop: 0, 4665, 0
stop: 1, 919005, 0
stop: 0, 4665, 0
stop: 0, 4665, 0
stop: 0, 4665, 0
stop: 0, 4665, 0
...
Как видим сборщик мусора уничтожает почти всё на первом проходе, и лишь иногда требуется второй. Правда, смущает цифра 919005.
Именно потому что не всё прибивается на первом проходе, а второй наступает нескоро.
Уменьшаем второй порог:
gc.set_threshold(10000, 10, 10)
Ага, теперь всё красиво:
...
stop: 1, 83970, 0
stop: 0, 9330, 0
stop: 0, 4665, 0
stop: 0, 4665, 0
stop: 1, 74640, 0
stop: 0, 4665, 0
stop: 0, 4665, 0
stop: 1, 102630, 0
stop: 0, 4665, 0
stop: 0, 4665, 0
...
Выводы
В результате всё просто. Используем слабые ссылки. Если это по каким-то причинам невозможно — поднимаем пороги.
Но при этом нужно помнить, что сборщик мусора будет запускаться реже.
Установка порогов в слишком большое значение способно в нашем случае съесть память не менее успешно, чем если бы эти значения оставались установленными по умолчанию.