Всё, что нужно знать о сборщике мусора в Python

https://rushter.com/blog/python-garbage-collector/
  • Перевод
Как правило, вам не нужно беспокоиться о сборщике мусора и работе с памятью когда вы пишете код на Python. Как только объекты больше не нужны, Python автоматически освобождает память из под них. Несмотря на это, понимание как работает GC поможет писать более качественный код.

Менеджер памяти


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

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

Таким образом, если долгоживущий Python процесс с течением времени начинает потреблять больше памяти, то это совсем не означает, что в вашем коде есть проблема с утечкой памяти. Если вы хотите узнать больше о менеджере памяти в Python, вы можете прочитать об этом в другой моей статье (англ.).

Алгоритмы сборки мусора


Стандартный интерпретатор питона (CPython) использует сразу два алгоритма, подсчет ссылок и generational garbage collector (далее GC), более известный как стандартный модуль gc из Python.

Алгоритм подсчета ссылок очень простой и эффективный, но у него есть один большой недостаток. Он не умеет определять циклические ссылки. Именно из-за этого, в питоне существует дополнительный сборщик, именуемый поколенческий GC, который следит за объектами с потенциальными циклическими ссылками.

В Python, алгоритм подсчета ссылок является фундаментальным и не может отключен, тогда как GC опционален и может быть отключен.

Алгоритм подсчета ссылок


Алгоритм подсчета ссылок это одна из самых простых техник для сборки мусора. Объекты удаляются как только на них больше нет ссылок.

В Python, переменные не хранят значения, а выступают в роли ссылок на объекты. То есть когда вы присваивайте значение новой переменной, то сначала создается объект с этим значением, а уже потом переменная начинает ссылаться на него. На один объект может ссылаться множество переменных.

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

Примеры, когда количество ссылок увеличивается:

  • оператор присваивания
  • передача аргументов
  • вставка нового объекта в лист (увеличивается количество ссылок для объекта)
  • конструция вида foo = bar (foo начинается ссылаться на тот же объект, что и bar)

Как только счетчик ссылок для определенного объекта достигает нуля интерпретатор запускает процесс уничтожения объекта. Если удаленный объект содержал ссылки на другие объекты, то эти ссылки также удаляются. Таким образом, удаление одного объекта может повлечь за собой удаление других.

Например, если удаляется список, то счетчик ссылок во всех его элементах уменьшается на один. Если все объекты внутри списка больше нигде не используются, то они также будут удалены.

Переменные, которые объявлены вне функций, классов и блоков называются глобальными. Как правило, жизненный цикл таких переменных равен жизни Python процесса. Таким образом, количество ссылок на объекты на которые ссылаются глобальные переменные никогда не падает до нуля.

Переменные, которые объявлены внутри блока (функции, класса) имеют локальную видимость (т.е. они видны только внутри блока). Как только интерпретатор питона выходит из блока он уничтожает все ссылки созданные локальными переменными внутри него.

Вы всегда можете проверить количество ссылок используя функцию sys.getrefcount.

Пример работы счетчика ссылок:

foo = []

# 2 ссылки, одна от переменной foo и одна от getrefcount
print(sys.getrefcount(foo))

def bar(a):
    # 4 ссылки
    # от foo, аргумента функции (a), getrefcount и одна от стека вызова функции
    print(sys.getrefcount(a))

bar(foo)
# 2 ссылки, локальные ссылки внутри функции уничтожены
print(sys.getrefcount(foo))

Основная причина, из-за которой стандартный интерпретатор (CPython) использует счетчик ссылок, является исторической. В настоящее время можно встретить множество дебатов по поводу данного подхода. Некоторые люди считают, что сборщик мусора может быть намного эффективней без участия алгоритма подсчета ссылок. У данного алгоритма есть множество проблем, таких как циклические ссылки, блокирование потоков, а также дополнительные накладные расходы на память и cpu.

Основным плюсом этого алгоритма является то, что объекты удаляются сразу как только они не нужны.

Дополнительный сборщик мусора


Зачем нам нужен дополнительный алгоритм, когда у нас уже есть подсчет ссылок?

К сожалению, классический алгоритм подсчета ссылок имеет один большой недостаток — он не умеет находить циклические ссылки. Циклические ссылки происходят когда один или более объектов ссылаются на друг друга.

Два примера:

image

Как вы можете видеть, объект lst ссылается сам на себя, тогда как object1 и object2 ссылаются друг на друга. Для таких объектов счетчик ссылок всегда будет равен 1.

Демонстрация на Python:

import gc

# используется ctypes для доступа к объектам по адресу памяти
class PyObject(ctypes.Structure):
    _fields_ = [("refcnt", ctypes.c_long)]


gc.disable()  # выключаем циклический GC

lst = []
lst.append(lst)

# сохраняем адрес списка lst
lst_address = id(lst)

# удаляем ссылку lst
del lst

object_1 = {}
object_2 = {}
object_1['obj2'] = object_2
object_2['obj1'] = object_1

obj_address = id(object_1)

# удаляем ссылки
del object_1, object_2

# раскомментируйте для запуска ручной сборки объектов с циклическими ссылками
# gc.collect()

# проверяем счетчик ссылок
print(PyObject.from_address(obj_address).refcnt)
print(PyObject.from_address(lst_address).refcnt)

В примере выше, инструкция del удаляет ссылки на наши объекты (не сами объекты). Как только Python выполняет инструкцию del эти объекты становятся недоступны из Python кода. Однако, с выключенным модулем gc они по прежнему будут оставаться в памяти, т.к. они имели циклические ссылки и их счетчик по прежнему равен единице. Вы можете визуально исследовать такие связи используя библиотеку objgraph.

Для того, чтобы исправить эту проблему, в Python 1.5 был добавлен дополнительный алгоритм, известный как модуль gc. Единственная задача которого удаление циклических объектов, к которым уже нет доступа из кода.

Циклические ссылки могут происходить только в “контейнерных” объектах. Т.е. в объектах, которые могут хранить другие объекты, например в списках, словарях, классах и кортежах. GC не следит за простыми и неизменяемыми типами, за исключением кортежей. Некоторые кортежи и словари так же исключаются из списка слежки при выполнении определенных условий. Со всеми остальными объектами гарантированно справляется алгоритм подсчета ссылок.

Когда GC срабатывает


В отличие от алгоритма подсчета ссылок, циклический GC не работает в режиме реального времени и запускается периодически. Каждый запуск сборщика создаёт микропаузы в работе кода, поэтому CPython (стандартный интерпретатор) использует различные эвристики, для определения частоты запуска сборщика мусора.

Циклический сборщик мусора разделяет все объекты на 3 поколения (генерации). Новые объекты попадают в первое поколение. Если новый объект выживает процесс сборки мусора, то он перемещается в следующее поколение. Чем выше поколение, тем реже оно сканируется на мусор. Так-как новые объекты зачастую имеют очень маленький срок жизни (являются временными), то имеет смысл опрашивать их чаще, чем те, которые уже прошли через несколько этапов сборки мусора.

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

Если сразу несколько или больше поколений преодолели порог, то выбирается наиболее старшее поколение. Это сделано из-за того, что старые поколения также сканируют все предыдущие. Чтобы сократить число пауз сборки мусора для долгоживущих объектов, самая старшая генерация имеет дополнительный набор условий.

Стандартные пороги срабатывания для поколений установлены на 700, 10 и 10 соответственно, но вы всегда можете их изменить с помощью функций gc.get_threshold и gc.set_threshold.

Алгоритм поиска циклов


Для полноценного описания алгоритма поиска циклов потребуется отдельная статья. Если кратко, то GC итерирует каждый объект из выбранных поколений и временно удаляет все ссылки от отдельно взятого объекта (все ссылки на которые этот объект ссылается). После полного прохода, все объекты, у которых счетчик ссылок меньше двух считаются недоступными из питона и могут быть удалены.

Для более глубокого понимания я рекомендую прочитать (прим. переводчика: английский материал) оригинальное описание алгоритма от Neil Schemenauer и функцию collect из исходников CPython. Описание из Quora и пост о сборщике мусора так же могут быть полезны.

Стоит отметить, что проблема с деструкторами описанная в оригинальном описании алгоритма была исправлена начиная с Python 3.4 (подробнее в PEP 442).

Советы по оптимизации


Циклы зачастую случаются в реальных задачах, их можно встретить в задачах с графами, связными списками или в структурах данных, где требуется вести учёт отношений между объектами. Если ваша программа имеет высокую нагрузку и требовательна к задержкам, то, по возможности, циклы лучше избегать.

В местах, где вы заведомо используйте циклические ссылки, можно использовать «слабые» ссылки. Слабые ссылке реализованы в модуле weakref и в отличие от обычных ссылок никак не влияют на счётчик ссылок. Если объект со слабой ссылок оказывается удалённым, то вместо него возвращается None.

В некоторых случаях полезно отключить автоматическую сборку модулем gc и вызывать его вручную. Для этого достаточно вызывать gc.disable() и в дальнейшем вызывать gc.collect() вручную.

Как найти и отладить циклические ссылки


Отладка циклов может быть мучительной, особенно если в вашем коде используется множество сторонних модулей.

Модуль gc предоставляет вспомогательные функции, которые могут помочь в отладке. Если параметры GC установить на флаг DEBUG_SAVEALL, то все недоступные объекты будут добавлены в список gc.garbage.

import gc

gc.set_debug(gc.DEBUG_SAVEALL)

print(gc.get_count())
lst = []
lst.append(lst)
list_id = id(lst)
del lst
gc.collect()
for item in gc.garbage:
    print(item)
    assert list_id == id(item)

Как только вы определите проблемное место — его можно визуализировать с помощью objgraph.

image

Заключение

Основной процесс сборки мусора выполняется алгоритмом подсчета ссылок, который очень простой и не имеет настроек. Дополнительный алгоритм используется только для поиска и удаления объектов с циклическими ссылками.

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

P.S.: Я являюсь автором этой статьи, можете задавать любые вопросы.
Поделиться публикацией

Похожие публикации

Комментарии 16

    +1
    Почему до сих пор не избавились от алгоритма подсчета ссылок? Разве он не устарел?
      +2
      Основным плюсом этого алгоритма является то, что объекты удаляются сразу как только они не нужны.

      Этот метод практически не уступает по производительности ручному управлению памятью, но при этом позволяет существенно сократить количество присущих ему ошибок. Поддержка кода также упрощается.
      А каким алгоритмом собственно предлагаете его заменить?
        +1
        zhovner tewak

        В случае с Python этот метод местами мешает ему, это одна из причин почему до сих пор никто не может избавится от GIL. Если убрать подсчет ссылок, то сразу сломается всё API, которое используют библиотеки, написанные с использованием компилиуремых языков (по сути любые компилируемые модули для питона). Из-за подсчета ссылок любое действие над объектом должно быть безопасным, т.к. постоянно меняется счетчик.

        Поэтому убирать в обозримом будущем его никто не будет, это сломает очень большое количество популярных библиотек.

        В производительном PyPy есть сразу 6 вариантов GC, которые можно выбирать отталкиваясь от задачи. Они сразу пошли по пути отказа от стандартного API CPython.
          0

          А почему нельзя использовать atomic integer как счётчик? Это частично решило бы проблему подсчёта ссылок в разных потоках.

            0
            Мне кажется всё опять упирается в API, в котором десятки лет четкие инструкции и порядок действий как работать с объектами, многие библиотеки просто сломаются. К тому же это может сильно замедлить работу (среднестатистическому скрипту потребуется более 10 000 atomic счетчиков), при этом не решает всех проблем.
            0
            Не очень понял, а почему «удаление потом» не является безопасным и не может использваться с компилируемыми языками? Имеется ввиду что подсчёт ссылок должен работать и вне интерпритатора, в нативном коде? Если да, то как мининмум можно оставить подсчёт ссылок только в нативном коде, а внутри интерпритатора запускать циклический сборщик, который будет учитывать досягаемость до объекта из пайтон кода и наличие ссылок на объект из нативного кода.
            Да и вообще был бы очень признателен если бы пояснили почему в питон мире сложилась такая ситуация, что есть официальный CPython с рядом проблем под капотом (GC, GIL, нету JIT) и есть как минимум один кастомный питон (PyPy) в котором эти проблемы решены.
              0
              Я имел ввиду, что компилируемые языки используют CPython C API, в котором при работе с объектами нужно вручную вызывать определенные функции, которые работают со счетчиками, захватывают GIL и т.д. Оставить подсчет ссылок в нативном коде наверное можно, но это будет хак и не решит всех проблем, такое никто не пропустит в Python. Проще начать с чистого листа и выпустить Python 4.

              Текущая модель работы с CPython просто не позволяет изменить многие моменты не ломая кучу библиотек (даже тех, которые написаны на чистом Python).

              По поводу почему такая ситуация сложилась — тут всё просто, Python уже почти 30 лет, многие вещи проектировались в 90х годах, тогда даже многоядерных процессоров не было. Разработчики Python считают, что нужно поддерживать обратную совместимость. Ну а для полноценного JIT Python непредназначен, уж слишком он динамический сейчас.
              0

              Во-первых, есть четкий список требований если вы хотите внедрить что-то своё, и это должно быть включено в CPython.
              Во-вторых, придётся переписывать несметное число бинарных модулей, которые это уже используют. (если вы не предложите достойной замены на уровне макросов PY_INCREF в C/C++)
              В третьих, прошу ознакомиться с проектом Gilectomy или хотя бы с видео с PyCon2017, который, возможно, заменит существующий GIL, если сможет удовлетворить в достаточной мере выше описанным требованиям.

            0
            не всё так однозначно.
            есть масса ситуаций(и очень частых) где refcounter наиболее эффективен.
            –2
            Если мы заговорили об этом — питон самое слабое место?
              0
              Плюсы и минусы всегда зависят от контекста. Например GIL, который часто считают слабым местом — это один из факторов позволивших добиться простой и удобной интероперабельности с Сишным кодом, что является сильной стороной Python.
                –3
                Я всегда спрашиваю в конкретном контексте. deadone — это не Вам. Если я летал на марс — то я и требую с меня как я летал на марс. Кто ставит минусы опять — автор боится отвечать, ибо и ему прилетит. Уже вон летит.
                0
                Если кратко, то GC итерирует каждый объект из выбранных поколений и временно удаляет все ссылки от отдельно взятого объекта (все ссылки на которые этот объект ссылается).

                Как GC может обойти все объекты в поколении? Неужели где-то явно есть список всех объектов этого поколения? Мне кажется, такой список занимал бы слишком много места, его дорого было бы поддерживать.
                  +1
                  Есть конечно, они связным списком хранятся и не так много занимают. В Python есть куча мест, где память намного больше используется, ради оптимизации скорости. Это же не C, где каждый байт экономят.
                  0

                  Здесь стоит упомянуть известный опыт instagram, где добились существенного прироста производительности просто отключив сборщик мусора.

                    0
                    Поправочка, они уже включили его обратно. Но к их чести, они сделали патчик в Python 3.7 который решил их предыдущую проблему.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое