Каждый экземпляр класса в 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)


  1. loltrol
    08.11.2019 23:52

    А что будет, если я все же решусь выстрелить себе в ногу, и шлепну p1.x, p2.x = p2, p1? Оно в памяти останется висеть?


    1. intellimath Автор
      09.11.2019 09:41

      Да, останется.


  1. Tiendil
    10.11.2019 10:29

    Интересно.

    Есть success story использования этой штуки? Хорошие замеры профита в производительности?


    1. intellimath Автор
      10.11.2019 17:04
      +1

      Вот jupyter notebook с некоторыми замерами по памяти и времени выполнения на (искусственных) примерах создания деревьев.


      1. Tiendil
        10.11.2019 17:08

        Любопытные метрики, спасибо.