Каждый экземпляр класса в CPython, созданный при помощи синтаксической конструкции class, участвует в механизме циклической сборки мусора. Это увеличивает след в памяти каждого экземпляра и может создавать проблемы с памятью в высоконагруженных системах.
Нельзя ли обойтись в случае необходимости одним базовым механизмом подсчета ссылок?
Разберем один подход, который поможет создавать классы, чьи экземпляры будут удаляться только с помощью механизма подсчета ссылок.
Немного о механизме сборки мусора в CPython
Основным механизмом для сборки мусора в Python является механизм подсчета ссылок. Каждый объект содержит поле, которое содержит текущее значение числа ссылок на него. Объект разрушается, как только значение счетчика ссылок станет равным нулю. Однако, он не позволяет утилизировать объекты, которые содержат циклические ссылки. Например,
lst = []
lst.append(lst)
del lst
В таких случаях после удаления объекта счетчик ссылок на него остается больше нуля. Для решения этой проблемы в СPython имеет дополнительный механизм, который отслеживает объекты и разрывает циклы в графе ссылок между объектами. О том как работает механизм циклической сборки мусора в CPython3 есть хорошая статья.
Накладные расходы, связанные с механизмом циклической сборки мусора
Обычно механизм циклической сборки мусора не создает проблем. Но с ним связаны определенные накладные расходы:
К каждому экземпляру класса при распределении памяти добавляется заголовок PyGC_Head: (не менее 24 байта на Python <= 3.7 и не менее 16 байтов в 3.8 на 64-битной платформе.
Это может создать проблему дефицита памяти, если вы запускаете много экземпляров одного процесса, в котором необходимо иметь одновременно очень большое количество объектов с относительно небольшим числом атрибутов, а объем памяти ограничен.
Можно ли ограничиться иногда базовым механизмом подсчета ссылок?
Механизм циклической сборки мусора может оказаться избыточным, когда класс представляет нерекурсивный тип данных. Например, записи, содержащие значения простого типа (числа, строки, дата/время). Для иллюстрации рассмотрим простой класс:
class Point:
x: int
y: int
При корректном его использовании циклы ссылок невозможны. Хотя в Python ничто не мешает "выстрелить себе в ногу":
p = Point(0, 0)
p.x = p
Тем не менее для класса Point
как раз можно было бы ограничиться механизмом подсчета ссылок. Но стандартного механизма отказа от циклической сборки мусора для отдельно взятого класса пока нет.
Современный CPython устроен так, что при определении пользовательских классов в структуре, отвечающей за тип, который определяет пользовательский класс, всегда устанавливается флаг Py_TPFLAGS_HAVE_GC. Он определяет, что экземпляры класса будут включены в механизм циклического сборки мусора. Для всех таких объектов при его создании добавляется заголовок PyGC_Head, осуществляется их включение в список отслеживаемых объектов. Если флаг Py_TPFLAGS_HAVE_GC
не установлен, то работает только базовый механизм подсчета ссылок. Однако, одним сбросом Py_TPFLAGS_HAVE_GC
обойтись не получится. Потребуется внести изменения в ядро CPython, ответственное за создание и разрушение экземпляров. А это пока проблематично.
Об одной реализации
В качестве примера реализации идеи рассмотрим базовый класс dataobject
из проекта recordclass. С его помощью можно создавать классы, экземпляры которых не участвуют в механизме циклической сборки мусора (Py_TPFLAGS_HAVE_GC
не установлен и, соответственно, нет дополнительного заголовка PyGC_Head
). Они имеют точно такую же структуру в памяти, как и экземпляры классов со __slots__, но без PyGC_Head
:
from recordclass import dataobject
class Point(dataobject):
x:int
y:int
>>> p = Point(1,2)
>>> print(p.__sizeof__(), sys.getsizeof(p))
32 32
Для сравнения приведем аналогичный класс со __slots__
:
class Point:
__slots__ = 'x', 'y'
x:int
y:int
>>> p = Point(1,2)
>>> print(p.__sizeof__(), sys.getsizeof(p))
32 64
Разница в размерах как раз равна размеру заголовка PyGC_Head
. Для экземпляров с несколькими атрибутами такое увеличение размера его следа в оперативной памяти может оказаться существенным. Для экземпляров класса Point
добавление PyGC_Head
приводит к увеличению его размера в 2 раза.
Для достижения такого эффекта используется специальный метакласс datatype
, который обеспечивает настройку подклассов dataobject
. В результате настройки сбрасывается флаг Py_TPFLAGS_HAVE_GC
, базовый размер экземпляра tp_basicsize увеличивается на величину, необходимую для хранения дополнительных слотов для полей. Соответствующие имена полей перечисляются при объявлении класса (у класса Point
их два: x
и y
). Метакласс datatype
также обеспечивает установку значений слотов tp_alloc, tp_new, tp_dealloc, tp_free, которые реализуют корректные алгоритмы создания и разрушения экземпляров в памяти. По умолчанию, у экземпляров отсутствуют __weakref__ и __dict__ (как и у экземпляров классов со __slots__
).
Заключение
Как можно было убедиться, в CPython при необходимости возможно отключение механизма циклической сборки мусора для конкретного класса, когда есть уверенность в том, что его экземпляры не будут образовывать циклические ссылки. Это позволит уменьшить их след в памяти на величину заголовка PyGC_Head
.
Комментарии (5)
Tiendil
10.11.2019 10:29Интересно.
Есть success story использования этой штуки? Хорошие замеры профита в производительности?intellimath Автор
10.11.2019 17:04+1Вот jupyter notebook с некоторыми замерами по памяти и времени выполнения на (искусственных) примерах создания деревьев.
loltrol
А что будет, если я все же решусь выстрелить себе в ногу, и шлепну p1.x, p2.x = p2, p1? Оно в памяти останется висеть?
intellimath Автор
Да, останется.