Deep Engineering

MEASUREMENT

bench/gc/alive.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/python/garbage-collection
How to run it
python3.11 bench/gc/alive.py
python3.12 bench/gc/alive.py
python3.13 bench/gc/alive.py
python3.14 bench/gc/alive.py

python3.13 bench/gc/pause.py

python3.11 bench/gc/freethreaded.py    # и 3.12, 3.13, 3.14
python3.13t bench/gc/freethreaded.py   # сборки без GIL
python3.14t bench/gc/freethreaded.py
python3.13t bench/gc/alive.py
python3.14t bench/gc/alive.py

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «Сборка мусора»

Скрипт Что делает
alive.py наблюдения без времени: жив ли объект без цикла и в цикле, исключение в локальной переменной и в атрибуте, lru_cache на методе, связанный метод в списке подписчиков, __del__ в цикле
pause.py время полной сборки gc.collect() при 10 тысячах, 100 тысячах и миллионе живых списков без мусора, и то же после gc.freeze()
practice.py источник ответов двух практических задач урока
freethreaded.py сборка без GIL против обычной: экземпляр в одном потоке, функция уровня модуля, последняя ссылка в чужом потоке, gc.freeze() на миллионе живых списков
python3.11 bench/gc/alive.py
python3.12 bench/gc/alive.py
python3.13 bench/gc/alive.py
python3.14 bench/gc/alive.py

python3.13 bench/gc/pause.py

python3.11 bench/gc/freethreaded.py    # и 3.12, 3.13, 3.14
python3.13t bench/gc/freethreaded.py   # сборки без GIL
python3.14t bench/gc/freethreaded.py
python3.13t bench/gc/alive.py
python3.14t bench/gc/alive.py

Записи в runs/: без суффикса — 3.13.7 с GIL, остальные версии — с суффиксом; t в суффиксе — сборка без GIL (3.13t, 3.14t).

Как проверяется «жив ли объект»

Слабой ссылкой: она не увеличивает счётчик ссылок, а после смерти объекта возвращает None. Так проверка не держит то, что проверяет.

Сборщик циклов в alive.py выключен на время опытов 1–5. Иначе он мог бы сработать сам между двумя строками, и «жив до gc.collect()» превратилось бы в «жив, пока повезёт». gc.collect() при выключенном сборщике работает — gc.disable() отключает только автоматический запуск по порогам.

Ошибка, оставленная в истории

Первая редакция alive.py печатала вывод, написанный до прогона. В сюжете «исключение в атрибуте» после svc.last_error = None стояла подпись «в цикле с кадром, ждёт сборщика». Прогон показал, что объект умер сразу: имя e интерпретатор уже удалил, кадр на исключение не ссылается, и убранный атрибут опускает счётчик исключения до нуля. Цикл из справочника — кадр → исключение → трассировка → кадр — получается, только если исключение положить в локальную переменную той же функции; тогда после выхода из функции группа недостижима и ждёт сборщика.

Вторая редакция урока назвала случай с атрибутом «цепочкой, а не циклом», и это тоже было неточно: кадр ссылается на глобальные переменные модуля, а через них — снова на svc, так что цикл там формально есть. Различие, которое действительно показывает прогон, другое: достижимо ли всё это от живого объекта. Если да — gc.collect() объект не трогает (это теперь напечатано отдельной строкой в разделах 3–5), а убранная ссылка освобождает его сразу. Если нет — объект ждёт сборщика.

Что получилось

Все шесть опытов alive.py дают одинаковый вывод на 3.11–3.14 (кроме строки с версией и порогами: (700, 10, 10) на 3.11.15 и 3.12.3, (2000, 10, 10) на 3.13.7 и 3.14.7).

Полная сборка платит за живые объекты. На 3.13.7 без единого мусорного объекта: 0,75 мс при 10 тысячах живых списков, 3,57 мс при 100 тысячах, 39,88 мс при миллионе — порядка 30–40 нс на живой объект. На других версиях то же с точностью до шума машины.

gc.freeze() убирает живое из обхода: тот же миллион, 38545,7 мкс до и 0,3 мкс после.

Сборка без GIL (добавлено 02.10.2026)

Внешний аудит указал, что урок помечен 3.13 и 3.14 и при этом говорит про одну сборку CPython, хотя с 3.13 их две. Проверено запуском на 3.13.7 и 3.14.7 без GIL (sys.version: «experimental free-threading build» у 3.13.7 и «free-threading build» у 3.14.7).

Что совпало. Обычный экземпляр, созданный и отпущенный одним потоком, умирает на строке del и там. alive.py на сборках без GIL даёт тот же вывод, что с GIL, кроме одной строки: в разделе 6 gc.collect() вернул 3, а не 2. Третий объект — функция тела класса Finalized (видно через gc.DEBUG_SAVEALL): в обычной сборке она умирает счётчиком сразу после создания класса, без GIL — ждёт сборщика.

Что не совпало — freethreaded.py:

  • функция, объявленная на уровне модуля, после del жива и умирает только на gc.collect(); вложенная функция умирает сразу;
  • если последнюю ссылку удаляет не тот поток, что создал объект, в том потоке сразу после del объект жив: 20 из 20 на 3.13t, 19 из 20 на 3.14t — результат зависит от расписания потоков, поэтому печатается счёт. В главном потоке после join() — мёртв во всех попытках. С GIL — 0 из 20 на всех четырёх версиях;
  • gc.freeze() без GIL убирает из сборки не всё: на 3.14t 37,3 мс до и 23,2 мс после, на 3.13t 63,5 и 23,4 мс. С GIL после заморозки остаются доли микросекунды (0,2–0,3 мкс).

Абсолютные времена в разделе 4 не сравнимы с pause.py: куча к этому моменту устроена иначе, и на 3.13.7 с GIL сборка миллиона списков здесь заняла 86,9 мс против 39,88 мс в pause.py. Содержательно только сравнение «до и после» внутри одного прогона.

Сборщик сборки без GIL — отдельная реализация: в libpython3.14t.so строка пути исходника — Python/gc_free_threading.c, в libpython3.14.so — Python/gc.c (проверено strings).

Механизм — в PEP 703: у объекта есть поток-владелец (biased reference counting), а часть объектов считается отложенно. Дословные цитаты PEP для урока не сняты: при подготовке правки страница PEP была недоступна, и урок опирается на прогон, а не на пересказ.

Практика урока

practice.py — источник ответов двух задач, runs/practice.txt — дословная запись его прогона на 3.13.7. Вторая задача — во сколько раз дольше полная сборка при десятикратном росте числа живых объектов: 9,8 на 3.13.7, 9,8–10,3 на четырёх версиях.

Script

202 lines
"""Почему объект жив, хотя ссылок на него «нет»: циклы и скрытые ссылки.

Времени здесь нет — только то, жив ли объект в данный момент. Живость
проверяется слабой ссылкой: она объект не держит, а после его смерти
возвращает None. Так проверка не мешает тому, что проверяет.

ШЕСТЬ СЮЖЕТОВ:

1. БЕЗ ЦИКЛА. `del` последней ссылки — и объект умирает сразу, на этой же
   строке. Сборщик циклов здесь не участвует.

2. ЦИКЛ. Две вершины ссылаются друг на друга; `del` обоих имён — и обе
   живы. Умирают только на `gc.collect()`, и он же возвращает число
   найденных недостижимых объектов.

3. ИСКЛЮЧЕНИЕ. `except ... as e` чистит имя в конце блока — и справочник
   объясняет почему: трассировка держит кадр, кадр держит локальные
   переменные, и исключение в локальной переменной замыкает цикл. Здесь
   проверены оба случая: исключение в локальной переменной (после выхода из
   функции группа недостижима — объект ждёт сборщика) и в атрибуте живого
   объекта (всё достижимо от живого держателя — сборщик объект не тронет, а
   убранная ссылка освобождает его сразу).

4. LRU_CACHE НА МЕТОДЕ. Кеш держит `self` в ключе, пока запись не вытеснена
   или кеш не очищен.

5. СВЯЗАННЫЙ МЕТОД В РЕЕСТРЕ. Подписка `callbacks.append(obj.on_event)` —
   это ссылка на `obj`. `weakref.WeakMethod` её не держит.

6. __del__ В ЦИКЛЕ. До 3.4 такой цикл сборщик не трогал и складывал в
   `gc.garbage`; после PEP 442 он собирается, а `gc.garbage` пуст.

Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import functools
import gc
import sys
import weakref

print("PY", sys.version.split()[0], "| пороги", gc.get_threshold())
print()


def alive(ref):
    return "жив" if ref() is not None else "мёртв"


class Node:
    def __init__(self, name):
        self.name = name
        self.other = None


# Сборщик выключен на время опытов 1–5: иначе он может сработать сам посреди
# сюжета, и «жив до gc.collect()» превратится в «жив, пока повезёт». Включён
# он обратно перед шестым опытом и в конце.
gc.collect()
gc.disable()

# --- 1. Без цикла ------------------------------------------------------------------
print("1) без цикла")
a = Node("a")
r = weakref.ref(a)
print("   до del:", alive(r))
del a
print("   сразу после del:", alive(r), "— освобождён счётчиком ссылок, сборщик не нужен")
print()

# --- 2. Цикл -----------------------------------------------------------------------
print("2) цикл из двух вершин")
a, b = Node("a"), Node("b")
a.other, b.other = b, a
ra, rb = weakref.ref(a), weakref.ref(b)
print("   sys.getrefcount(a) до del:", sys.getrefcount(a))
del a, b
print("   после del a, b:", alive(ra), "и", alive(rb))
found = gc.collect()
print(f"   gc.collect() вернул {found}; теперь:", alive(ra), "и", alive(rb))
print()

# --- 3. Исключение ---------------------------------------------------------------------
print("3) исключение и кадр упавшей функции")


class Payload:
    """Крупный объект, живущий в локальной переменной упавшей функции."""


def work(sink, keep=None, local=False):
    payload = Payload()
    sink.append(weakref.ref(payload))
    try:
        raise ValueError("сбой")
    except ValueError as e:
        if keep is not None:
            keep.last_error = e
        if local:
            err = e  # noqa: F841 — та самая локальная ссылка из справочника


class Service:
    last_error = None


refs = []
work(refs)
print("   исключение нигде не сохранено: payload после выхода из функции —", alive(refs[-1]))

work(refs, local=True)
print("   сохранено в локальную err: payload после выхода —", alive(refs[-1]))
print("     цикл: кадр -> err -> исключение -> __traceback__ -> кадр")
gc.collect()
print("     после gc.collect():", alive(refs[-1]))

svc = Service()
work(refs, keep=svc)
print("   сохранено в svc.last_error: payload после выхода —", alive(refs[-1]))
print("     путь от живого svc: svc -> исключение -> __traceback__ -> кадр -> payload")
gc.collect()
print("     gc.collect() при живом svc:", alive(refs[-1]))
svc.last_error = None
print("     svc.last_error = None:", alive(refs[-1]))

try:
    raise ValueError("сбой")
except ValueError as e:
    pass
try:
    e  # noqa: B018
    print("   имя e после блока except: существует")
except NameError:
    print("   имя e после блока except: NameError — его удалил сам интерпретатор")
print()

# --- 4. lru_cache на методе -------------------------------------------------------------
print("4) functools.lru_cache на методе")


class Report:
    @functools.lru_cache(maxsize=128)
    def total(self, n):
        return n * 2


rep = Report()
rep.total(10)
rr = weakref.ref(rep)
del rep
print("   del rep:", alive(rr), "— self лежит в ключе кеша")
gc.collect()
print("   gc.collect():", alive(rr))
Report.total.cache_clear()
print("   Report.total.cache_clear():", alive(rr))
print()

# --- 5. Связанный метод в реестре --------------------------------------------------------
print("5) связанный метод в списке подписчиков")


class Widget:
    def on_event(self):
        return "ok"


callbacks = []
w = Widget()
callbacks.append(w.on_event)
rw = weakref.ref(w)
del w
print("   callbacks.append(w.on_event); del w:", alive(rw), "— метод держит __self__")
gc.collect()
print("   gc.collect():", alive(rw))
callbacks.clear()
print("   callbacks.clear():", alive(rw))

weak_callbacks = []
w = Widget()
weak_callbacks.append(weakref.WeakMethod(w.on_event))
rw = weakref.ref(w)
del w
print("   то же через weakref.WeakMethod; del w:", alive(rw))
print("   weak_callbacks[0]() ->", weak_callbacks[0]())
print()

# --- 6. __del__ в цикле ------------------------------------------------------------------
print("6) __del__ у обеих вершин цикла")
gc.enable()
order = []


class Finalized(Node):
    def __del__(self):
        order.append(self.name)


a, b = Finalized("a"), Finalized("b")
a.other, b.other = b, a
del a, b
found = gc.collect()
print(f"   gc.collect() вернул {found}; финализаторы выполнились: {order}")
print(f"   gc.garbage: {gc.garbage}")