Deep Engineering

MEASUREMENT

bench/gc/freethreaded.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

142 lines
"""Сборка без GIL: где «объект умирает на той же строке» перестаёт быть правилом.

ЗАЧЕМ ЭТОТ ФАЙЛ. Урок о сборке мусора говорит: объект без цикла умирает, как
только пропадает последняя ссылка, на этой же строке. Для обычной сборки
CPython — с GIL — это так, и `alive.py` это показывает. Но с 3.13 у CPython
есть вторая сборка, без GIL (PEP 703), и в ней счётчик ссылок устроен иначе:
у объекта есть поток-владелец, а часть объектов считается отложенно. Внешний
аудит 02.10.2026 указал, что урок помечен 3.13 и 3.14 и при этом молча
говорит про одну сборку. Этот файл проверяет, где правило держится, а где нет,
запуском на обеих.

ЧЕТЫРЕ СЮЖЕТА:

1. ЭКЗЕМПЛЯР В ОДНОМ ПОТОКЕ. Обычный объект, созданный и отпущенный одним
   потоком. Ожидание — умирает на строке `del` в обеих сборках.

2. ФУНКЦИЯ, ОБЪЯВЛЕННАЯ НА УРОВНЕ МОДУЛЯ. В сборке без GIL у части объектов
   счётчик ссылок отложенный — такие объекты освобождает только сборщик.
   Проверяется, жива ли функция после `del` и после `gc.collect()`.

3. ПОСЛЕДНЮЮ ССЫЛКУ ОТПУСКАЕТ ЧУЖОЙ ПОТОК. Объект создан главным потоком,
   последнюю ссылку забирает и удаляет другой. Проверяется, жив ли объект в
   том потоке сразу после `del` и в главном после `join()`. Результат здесь
   зависит от расписания потоков, поэтому опыт повторён 20 раз и печатается
   счёт, а не один ответ.

4. gc.freeze() И МИЛЛИОН ЖИВЫХ СПИСКОВ. В обычной сборке после заморозки
   полная сборка перестаёт их обходить (`pause.py`). Здесь то же сравнение
   тем же способом — на обеих сборках.

Сборщик выключен на время сюжетов 1–3, чтобы не сработать сам между строками.

Запускать: python3.11 / 3.12 / 3.13 / 3.14 и python3.13t / 3.14t.
Вывод: runs/freethreaded.txt (3.13.7 с GIL) и runs/freethreaded-<версия>.txt.
"""
import gc
import sys
import sysconfig
import threading
import time
import weakref

FREE = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
print("PY", sys.version.split()[0], "|", "сборка без GIL" if FREE else "сборка с GIL")
print()


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


class Payload:
    pass


gc.collect()
gc.disable()

# --- 1. Экземпляр в одном потоке ------------------------------------------------
print("1) экземпляр, один поток")
obj = Payload()
r = weakref.ref(obj)
del obj
print(f"   сразу после del: {alive(r)}")

# --- 2. Функция уровня модуля ---------------------------------------------------
print("2) функция, объявленная на уровне модуля")


def handler():
    return None


r = weakref.ref(handler)
del handler
print(f"   сразу после del: {alive(r)}")
gc.collect()
print(f"   после gc.collect(): {alive(r)}")


def make():
    def inner():
        return None

    return inner


f = make()
r = weakref.ref(f)
del f
print(f"   для сравнения, вложенная функция сразу после del: {alive(r)}")

# --- 3. Последнюю ссылку отпускает чужой поток ----------------------------------
print("3) последнюю ссылку удаляет не тот поток, что создал объект")
TRIES = 20
in_thread = 0
after_join = 0
for _ in range(TRIES):
    box = [Payload()]
    r = weakref.ref(box[0])
    seen = {}

    def worker():
        o = box.pop()
        del o
        seen["alive"] = r() is not None

    t = threading.Thread(target=worker)
    t.start()
    t.join()
    in_thread += seen["alive"]
    after_join += r() is not None
print(f"   жив в том потоке сразу после del: {in_thread} из {TRIES}")
print(f"   жив в главном потоке после join(): {after_join} из {TRIES}")

gc.enable()

# --- 4. gc.freeze() и миллион живых списков -------------------------------------
# Тот же способ, что в pause.py: списки из одного элемента, лучшее из семи
# вызовов gc.collect() подряд.
REPEAT = 7
print(f"4) gc.freeze() и миллион живых списков, лучшее из {REPEAT} вызовов gc.collect()")


def best_collect():
    best = float("inf")
    for _ in range(REPEAT):
        t0 = time.perf_counter()
        gc.collect()
        best = min(best, time.perf_counter() - t0)
    return best


live = [[i] for i in range(1_000_000)]
before = best_collect()
gc.freeze()
after = best_collect()
frozen = f"{gc.get_freeze_count():,}".replace(",", " ")
print(f"   до gc.freeze():    {before * 1e6:9.1f} мкс")
print(f"   после gc.freeze(): {after * 1e6:9.1f} мкс, в постоянном поколении {frozen} объектов")
gc.unfreeze()