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

Итак, продолжим.

Side note

Прежде чем приступить к основному рассказу, хочу сообщить, что на прошлой неделе мне повезло выступить на конференции ITGorky 2025, где я попытался рассказать о механизмах работы сборщика мусора за час. К сожалению, ограниченное время и формат конференции не позволяют глубоко погрузиться в детали реализации, но если вам интересно забежать вперед — то можно посмотреть видео и презентацию. Также хочу отметить, что коллеги сделали очень интересные доклады — рекомендую — Список докладов с PythoNN в рамках ITGorky.

Оглавление

  1. Сборка мусора

  2. Стартовая функция, для запуска сборки мусора

  3. Сборка мусора в молодом поколении

  4. Детали реализации

  5. Дерегистрация кортежей

  6. Заключение

Сборка мусора

Как было сказано ранее, в последних версиях CPython запланированная сборка мусора происходит при выполнении определенных байткод‑инструкций виртуальной машины. Сборка мусора вызывается с причиной (reason) _PyGC_REASON_HEAP и номером поколения 1.

Ранее (до версии 3.14) указание такой причины означало сборку мусора для автоматически выбранного поколения. Поколение выбиралось в зависимости от накопленной статистики и граничных значений. Эти граничные значения можно изменить посредством вызова gc.set_threshold — функция позволяет задать три граничных значения. В версиях до 3.14 они означают следующее:

  1. Первое значение — количество живых объектов в молодом поколении. Количество живых объектов в молодом поколении проверяется при планировании сборки мусора и если оно превышает граничное значение, то это является одним из условий для успешного планирования (Планирование сборки мусора).

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

  3. Третье значение — аналогично второму, но для среднего поколения. Т.е. сколько раз должна выполниться сборка мусора для среднего поколения, чтобы запустилась сборка мусора в старшем поколении.

Тут надо сделать небольшое уточнение — запуск сборки мусора для старшего поколения зависит не только от граничных значений, но и общего количества живых объектов. В 2009 году (скорей всего в версии 3.1) добавлено дополнительное условие — количество объектов ожидающих сборку мусора должно быть больше 25% от общего количества живых объектов для запуска сборки мусора в старшем поколении. Количество ожидающих сборку мусора рассчитывается как количество объектов, которые пережили сборку в среднем поколении после последней сборки старшего поколения. Данное условие было добавлено, так как на некоторых сценариях сборка мусора в старшем поколении могла требовать квадратичного времени от общего числа живых объектов. Подробнее можно прочитать по следующим ссылкам:

  1. cpython/InternalDocs/garbage_collector.md

  2. Building a list of tuples has non-linear performance

И тут стоит отметить, что данное условие long_lived_pending / long_lived_total > 25% приводило к тому, что чем больше создано живых объектов, тем реже запускается сборка мусора. Но чем реже у нас запускается сборка мусора, тем большее количество объектов сборщик мусора вынужден обработать и тем дольше каждый запуск выполняется. Таким образом мы приходим к ситуации, когда паузы между сборками мусора и само время выполнения сборки мусора сложно прогнозировать.

Это стало одной из причин, почему была добавлена инкрементальная сборка мусора (впервые добавлена в 3.12, затем в 3.13 отменена и снова реализована в 3.14).

Начиная с версии 3.14 смысл граничных значений изменился. Первое значение как и прежде означает количество живых объектов в молодом поколении и имеет тот же смысл, что и раньше. Но смысл второго граничного значения поменялся — теперь оно означает какая доля старшего поколения должна быть обработана сборщиком мусора при его вызове. А третье значение теперь не используется вовсе.

Итак, запланированная сборка мусора вызывается с причиной _PyGC_REASON_HEAP и номером поколения 1. Номер поколения 1 означает запуск инкрементальной сборки мусора.

Какие еще причины используются при запуске сборки мусора:

  1. _PyGC_REASON_MANUAL — означает, что сборка мусора запущена пользователем посредством вызова gc.collect или вызова функции PyGC_Collect (интересный факт — PyGC_Collect вызывается при финализации интерпретатора, то есть при финализации помимо прочего используется причина PyGC_REASON_MANUAL).

  2. _PyGC_REASON_SHUTDOWN — означает, что сборка мусора запущена при финализации интерпретатора (мы разберем что происходит при завершении работы интерпретатора в одной из следующих статей). Только для данной причины существуют какие‑то дополнительные действия при сборке мусора, вернее отсутствуют — пользовательские обратные вызовы не выполняются.

Как было сказано выше, заданный номер поколения равный 1 означает инкрементальную сборку, что отличается от того, что было ранее — сборки мусора в среднем поколении. Номера поколений 0 и 2 — имеют тот же смысл, что и раньше — сборку мусора только для молодого поколения и полную сборку мусора, соответственно.

Во всех случаях происходит вызов функции _PyGC_Collect, которая является стартовой и которую разберем ниже.

Cтартовая функция, для запуска сборки мусора

_PyGC_Collect cтартовая функция, для запуска сборки мусора. Она довольно прямолинейная и выполняет следующий ряд задач.

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

  1. Повторный незащищенный вызов сборки мусора может привести к неопределенным последствиям, так как каждый тип сборки мусора вносит изменения в состояние gcstate, а также формирует собственный список объектов для работы. Повторный вызов может произойти при выполнении определенных пользователем функций, связанных с временем жизни объекта — tp_finalize, tp_clear, tp_dealloc или обратными вызовами для слабых ссылок. Любые пользовательские функции могут выполнять произвольные действия, в которых может быть явный вызов gc.collect или неявный через выполнение условий планирования сборки мусора.

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

Маркер выполнения сборки мусора находится в gcstate->collecting и обновляется с использованием атомарных инструкций. Также этот маркер проверяется при планировании сборки мусора.

В версии 3.14 стали официально доступны субинтерпретаторы (PEP-734: Субинтерпретаторы в Python 3.14). Каждый субинтерпретатор имеет собственный сборщик мусора, который практически не пересекается со сборщиками мусора из других субинтерпретаторов (субинтерпретаторы могут совместно использовать бессмертные объекты). Это означает, что мы можем использовать различные настройки граничных значений или совсем отключить сборку мусора для некоторых субинтерпретаторов и таким образом, например, повысить их производительность.

Следующая задача — исполнить обратные вызовы, которые пользователь добавил через gc.callbacks. Обратные вызовы совершаются до начала сборки мусора и после, с ответствующей пометкой — start и stop соответственно. Помимо этого вызываются DTrace‑хуки, аналогично — до и после выполнения сборки мусора.

Как было уже сказано, если сборка мусора вызывается с причиной _PyGC_REASON_SHUTDOWN, то обратные вызова совершены не будут, но DTrace‑хуки будут выполнены в любом случае.

Следующее что необходимо сделать — сохранить текущее исключение, если оно есть, и восстановить его после завершения сборки мусора. Делается это потому, что в ходе работы сборщика мусора может выполняться произвольный пользовательский код, относительно корректности которого нет никаких гарантий. Если при этом возникают исключения, то они поглощаются сборщиком мусора с выводом соответствующего сообщенгия в std.error. Если исключение не сохранять до сборки мусора, то любое исключение во время работы его перезатрет и оно будет потеряно.

И самое главное — вызвать сборку мусора с указанным типом (в молодом поколении, полную или инкрементальную). Помимо этого обновляется статистика, выполняются отладочные проверки консистентности состояния gcstate, но мы эти вещи опустим.

Сборка мусора в молодом поколении

Создание любого объекта, поддерживающего сборку мусора, приводит к добавлению этого объекта в список объектов молодого поколения. Происходит это посредством вызова функции PyObject_GC_Track из конструктора объекта.

Сборка мусора в молодом поколении вызывается только явно из пользовательского кода — это важный момент.

И тут, я считаю, необходимо напомнить как работает механизм подсчета ссылок.

Каждый объект Python имеет счетчик ссылок, при создании он инициализируется значением 1. Но что происходит дальше? В общем случае возможны два варианта — созданный объект добавляется в контейнерный объект или никуда не добавляется. В последнем случае после создания объект будет сразу же уничтожен, так как его значение счетчика ссылок будет уменьшено до нуля. Ниже пример простейшей программы на Python, демонстрирующий последний пример, и соответствующий байткод:

object() # создаем простой объект

#   0           RESUME                   0

#   1           LOAD_NAME                0 (object)
#               PUSH_NULL
#               CALL                     0
#               POP_TOP
#               LOAD_CONST               0 (None)
#               RETURN_VALUE

Разберем пример. После создания объекта (байткоды LOAD_NAME + PUSH_NULL + CALL) следует байткод POP_TOP, основная цель которого освободить объект на стеке, который был туда помещен предыдущим байткодом. В нашем случае CALL размещает на стеке только что созданный объект и, таким образом, мы имеем на стеке объект, у которого счетчик ссылок равен 1. Сразу же после POP_TOP это значение тут же понижает до 0, после чего для объекта вызывается деструктор. Даже если конструктор объекта поместил объект в молодое поколение сборщика мусора, то деструктор этот объект оттуда удалит.

Но как мы можем видеть, создание временного объекта не атомарная процедура — она состоит из двух байткодов — CALL и POP_TOP. Как мы уже знаем, в зависимости от состояния сборщика мусора, после CALL может быть вызвана сборка мусора (так как этот байткод имеет маркер для периодической проверки событий виртуальной машины). Даже если сборка мусора будет вызвана временный объект ее переживет и будет удален в POP_TOP (подробнее почему объект переживет сборку мусора мы рассмотрим позднее).

Теперь разберем пример чуть сложнее — пусть мы создаем именованный объект (строго говоря — мы создаем безымянный объект и связываем его с именем — подробнее у Никиты в видео).

a = object() # создаем пустой объект и связываем его с именем 'a'
b = object() # создаем пустой объект и связываем его с именем 'b'
	
#   0           RESUME                   0

#   1           LOAD_NAME                0 (object)
#               PUSH_NULL
#               CALL                     0
#               STORE_NAME               1 (a)

#   2           LOAD_NAME                0 (object)
#               PUSH_NULL
#               CALL                     0
#               STORE_NAME               2 (b)
#               LOAD_CONST               0 (None)
#               RETURN_VALUE

Как мы видим — в данном случае после CALL идет байткод STORE_NAME, который и сохраняет созданный объект в контейнере — в нашем случае в списке локальных переменных и, таким образом, на объект появляется внешняя ссылка, которая и продлевает время его жизни. Аналогично, после CALL может произойти вызов сборщика мусора, например, после создания объекта b — в этот момент объект b еще временный объект и для него срабатывает то же правило, что и в прошлом примере, а для объекта a время жизни продлевается контейнером locals.

Интересно посмотреть, что происходит при выполнении STORE_NAME:

inst(STORE_NAME, (v -- )) {
    PyObject *name = GETITEM(FRAME_CO_NAMES, oparg);
    PyObject *ns = LOCALS();
    int err;
    if (ns == NULL) {
        _PyErr_Format(tstate, PyExc_SystemError,
                        "no locals found when storing %R", name);
        PyStackRef_CLOSE(v);
        ERROR_IF(true);
    }
    if (PyDict_CheckExact(ns)) {
        err = PyDict_SetItem(ns, name, PyStackRef_AsPyObjectBorrow(v));
    }
    else {
        err = PyObject_SetItem(ns, name, PyStackRef_AsPyObjectBorrow(v));
    }
    PyStackRef_CLOSE(v);
    ERROR_IF(err);
}

Владение созданным объектом будет передано контейнеру — PyDict_SetItem(...) — после этого вызова счетчик ссылок у временного объекта будет иметь значение 2, затем у этого же объекта значение счетчика ссылок будет уменьшено посредством PyStackRef_CLOSE(...) (это в общем случае приведет к Py_DECREF). И таким образом, после создания временного объекта и связывания его с именем значение счетчика ссылок для него будет равно 1 (мы не рассматриваем варианты, когда конструктор намеренно создает циклические ссылки на создаваемый объект). Т.е. единственным владельцем созданного объекта будет выступать контейнер.

Что произойдет если объект удалить из контейнера, или контейнер выйдет из области видимости?

Посмотрим на следующий пример, где созданный объект удаляется:

a = object() # создаем объект 'a'
del a        # удаляем объект 'a'

#   0           RESUME                   0

#   1           LOAD_NAME                0 (object)
#               PUSH_NULL
#               CALL                     0
#               STORE_NAME               1 (a)

#   2           DELETE_NAME              1 (a)
#               LOAD_CONST               0 (None)
#               RETURN_VALUE

Мы знаем, что после создания объект a хранится в списке(словаре) локальных переменных и имеет значение счетчика ссылок равное 1. DELETE_NAME буквально удаляет имя a из словаря:

inst(DELETE_NAME, (--)) {
    PyObject *name = GETITEM(FRAME_CO_NAMES, oparg);
    PyObject *ns = LOCALS();
    int err;
    if (ns == NULL) {
        _PyErr_Format(tstate, PyExc_SystemError,
                        "no locals when deleting %R", name);
        ERROR_NO_POP();
    }
    err = PyObject_DelItem(ns, name);
    // Can't use ERROR_IF here.
    if (err != 0) {
        _PyEval_FormatExcCheckArg(tstate, PyExc_NameError,
                                    NAME_ERROR_MSG,
                                    name);
        ERROR_NO_POP();
    }
}

Далее в PyObject_DelItem происходит следующая цепочка вызовов dict_ass_sub -> PyDict_DelItem -> _PyDict_DelItem_KnownHash -> delitem_knownhash_lock_held -> delitem_common, где в итоге происходит Py_DECREF(old_value) — то есть помимо того, что мы удалили пару ключ‑значение из словаря, мы также у них понизили значение счетчика ссылок на единицу, что приведет к удалению объекта и вызову его деструктора и удалению из сборщика мусора.

Аналогичные рассуждения можно проделать и для случая, когда контейнерный объект выходит из области видимости, например, при выходе из функции список локальных переменных будет очищен, а значит будут очищены все его дочерние объекты, и если этот контейнер был единственным их владельцем — будут также уничтожены.

Как мы можем создать висящую циклическую ссылку? Можно показать на простом примере (в реальности она может создаваться через череду вызовов и ее может быть сложно обнаружить):

a = object()
a.a = a
del a

#   0           RESUME                   0

#   1           LOAD_NAME                0 (object)
#               PUSH_NULL
#               CALL                     0
#               STORE_NAME               1 (a)

#   2           LOAD_NAME                1 (a)
#               LOAD_NAME                1 (a)
#               STORE_ATTR               1 (a)

#   3           DELETE_NAME              1 (a)
#               LOAD_CONST               0 (None)
#               RETURN_VALUE

STORE_ATTR увеличит значение счетчика ссылок у объекта a и оно будет равно 2, а DELETE_NAME его уменьшит и значение будет 1, при этом в списке локальных переменных объект уже отсутствует — то есть выход локальных объектов из области видимости уже не повлияет на время жизни объекта a. Таким образом мы получили висячую циклическую ссылку, которую разрешить сможет только сборщик мусора.

К чему весь этот подробный рассказ о работе механизма подсчета ссылок? Для того, чтобы объяснить, что если мы намеренно не создавали висячих циклических ссылок, то в момент явного вызова сборщика мусора объекты либо уже уничтожены (POP_TOP, DELETE_NAME, STORE_NAME), либо они живы и сборщику мусора собирать нечего, а объекты переживут сборку.

Гипотеза поколений

В 1984 году Дэвид Унгар (David Ungar) сформулировал гипотезу, о том что а) молодые объекты долго не живут, б) выжившие объекты живут гораздо дольше:

Measurements of object lifetimes proved that young objects die young and old objects continue to live.

Generation Scavenging: A Non-disruptlve High Perfornmnce Storage Reclamation Algorithm

Интересно, что данное предположение было основано на основе Berkeley Smalltalk, который использовал подсчет ссылок.

В дальнейшем данная гипотеза получила название The Generational Hypothesis и лежит в основе многих алгоритмов сборки мусора с поколениями.

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

Плохо ли вызывать сборку мусора для молодого поколения явно? Зависит от ваших сценариев, в первую очередь. Но надо иметь в виду, что каждый такой вызов меняет статистику, от которой зависит планирование сборки мусора для среднего и старшего поколений до 3.14 и инкрементальной сборки в 3.14+.

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

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

Детали реализации

За сборку мусора в молодом поколении отвечает функция gc_collect_young:

static void
gc_collect_young(PyThreadState *tstate,
                 struct gc_collection_stats *stats)
{
    GCState *gcstate = &tstate->interp->gc;
    validate_spaces(gcstate);
    PyGC_Head *young = &gcstate->young.head;
    PyGC_Head *visited = &gcstate->old[gcstate->visited_space].head;
    untrack_tuples(young);
    GC_STAT_ADD(0, collections, 1);

    PyGC_Head survivors;
    gc_list_init(&survivors);
    gc_list_set_space(young, gcstate->visited_space);
    gc_collect_region(tstate, young, &survivors, stats);
    gc_list_merge(&survivors, visited);
    validate_spaces(gcstate);
    gcstate->young.count = 0;
    gcstate->old[gcstate->visited_space].count++;
    add_stats(gcstate, 0, stats);
    validate_spaces(gcstate);
}

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

  1. В первую очередь из списка объектов молодого поколения дерегистрируются кортежи (см. описание untrack_tuples ниже).

  2. Далее всем объектам поколения устанавливается номер старшего поколения, соответствующий текущему просмотренному старшему поколению. Номера поколений изменяются с 0 на 1 и наоборот после окончания инкрементальной сборки или полной сборки мусора (об этом поговорим при разборе инкрементальной сборки).

  3. Вызывается основная функция сборки мусора gc_collect_region.

  4. Все объекты, которые пережили сборку мусора будут перемещены в текущее просмотренное старшее поколение.

  5. Список объектов молодого поколения после вызова предыдущей функции больше не содержит элементов (в общем случае это не совсем так, но к этому вернемся когда будем разбирать gc_collect_region). Этот факт также фиксируется в переменной, которая хранит количество объектов в молодом поколении.

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

В отладочной сборке выполняются проверки, что все поколения (молодое, старое и постоянное) находятся в консистентном состоянии до и после вызова сборки мусора.

Выше было сказано, что всем объектам молодого поколения явно прописывается номер старшего поколения — сделано это было в следующем реквесте GH-117108: Set the «old space bit» to «visited» for all young objects и, согласно объяснению разработчиков, связано с тем, что некоторые объекты из ожидающей части могли попадать в просмотренную часть с неправильным номером. Несмотря на то что этот реквест в 3.13 был отменен, так как в инкрементальном сборщике обнаружили проблемы, в последствие эти изменения были сделаны в рамках реализации новой версии инкрементального сборщика мусора.

Дерегистрация кортежей

Кортежи наряду со словарями интенсивно используются в работе программ на Python. Довольно часто кортежи содержат non-gc объекты, такие как числа, строки, None или подобные объекты. Но при создании кортежей мы не можем быть уверены, что все объекты, которые составляют кортеж поддерживают или не поддерживают сборщик мусора. Связано это с тем, что один из способов конструирования кортежей состоит из двух этапов — сначала создание кортежа PyTuple_New(Py_ssize_t len), а затем его заполнение PyTuple_SetItem/PyTuple_SET_ITEM.

Идея оптимизации в том, чтобы исключить из текущего списка объектов такие кортежи, которые состоят из объектов, которые не поддерживают сборку мусора или сконструированы еще не полностью. Сама функция untrack_tuples прямолинейная как нельзя — единственное, что стоит здесь отметить — ссылка на следующий элемент в списке берется до вызова _PyTupleMaybeUntrack. Это делается потому, что _PyTupleMaybeUntrack может удалить op из списка, в этом случае его указатели на следующий и предыдущий элементы в списке будут обнулены и мы не сможем продвинуться.

static void
untrack_tuples(PyGC_Head *head)
{
    PyGC_Head *gc = GC_NEXT(head);
    while (gc != head) {
        PyObject *op = FROM_GC(gc);
        PyGC_Head *next = GC_NEXT(gc);
        if (PyTuple_CheckExact(op)) {
            _PyTuple_MaybeUntrack(op);
        }
        gc = next;
    }
}

В _PyTupleMaybeUntrackкак раз проверяет условия, о которых сказали ранее:

void
_PyTuple_MaybeUntrack(PyObject *op)
{
    PyTupleObject *t;
    Py_ssize_t i, n;

    if (!PyTuple_CheckExact(op) || !_PyObject_GC_IS_TRACKED(op))
        return;
    t = (PyTupleObject *) op;
    n = Py_SIZE(t);
    for (i = 0; i < n; i++) {
        PyObject *elt = PyTuple_GET_ITEM(t, i);
        /* Tuple with NULL elements aren't
           fully constructed, don't untrack
           them yet. */
        if (!elt ||
            _PyObject_GC_MAY_BE_TRACKED(elt))
            return;
    }
    _PyObject_GC_UNTRACK(op);
}
static inline int _PyObject_GC_MAY_BE_TRACKED(PyObject *obj)
{
    if (!PyObject_IS_GC(obj)) {
        return 0;
    }
    if (PyTuple_CheckExact(obj)) {
        return _PyObject_GC_IS_TRACKED(obj);
    }
    return 1;
}

Стоит немного объяснить _PyObjectGC_MAY_BE_TRACKED - мы видим, что если в кортеже лежит кортеж, то для него просто проверяется - содержится ли он в сборщике мусора или нет. Этой проверки достаточно, нам не требуется выполнять проверку рекурсивно для всех объектов вложенного кортежа.

Тут возможно следующие варианты:

  1. Вложенный кортеж является полностью сконструированным. По умолчанию он поддерживает сборку мусора. Если после его создания была выполнена сборка мусора, то для уже выполнялась процедура untrack_tuples, которая уже приняла решение — должен ли этот кортеж поддерживать сборку или нет. Это решение остается актуальным, так как кортеж является иммутабельным объектом и его дочерние объекты не поменяют свою принадлежность к сборщику мусора.

  2. Если же этот кортеж еще полностью не сконструирован, то по умолчанию он поддерживает сборку мусора.

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

Заключение

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

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

Спасибо за внимание.

Комментарии (2)


  1. Krokochik
    27.09.2025 16:28

    "Итак" слитно ;)


    1. zzzzzzerg Автор
      27.09.2025 16:28

      Спасибо!