ЗАМЕР
bench/gc/pause.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/interview/python/garbage-collection
- Как запустить
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
Запись прогона
Замеры для урока «Сборка мусора»
| Скрипт | Что делает |
|---|---|
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 на
четырёх версиях.
Скрипт
68 строк"""Сколько стоит полная сборка и за что именно платят.
ЧТО ПРОВЕРЯЕТСЯ. Полная сборка обходит все отслеживаемые объекты, в том
числе живые, — даже если мусора нет ни одного. Поэтому её время растёт с
числом ЖИВЫХ контейнеров, а не с числом умерших. Здесь это видно прямо:
`gc.collect()` на куче без единого цикла, при 10 тысячах, 100 тысячах и
миллионе живых списков.
И второе — `gc.freeze()`: переносит всё отслеживаемое в постоянное
поколение, и следующие сборки его не обходят. Документация предлагает это
для процессов, которые делают fork без exec; здесь снято, во что это
обходится по времени самой сборки.
КАК МЕРЯЕТСЯ. Лучшее из семи вызовов `gc.collect()` подряд: мусора между ними
нет, так что вызовы одинаковы, а минимум отсекает случайные просадки машины.
Число живых объектов — списки из одного элемента: список — контейнер, за ним
сборщик следит (`gc.is_tracked`), а у int внутри — нет.
Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import gc
import sys
import time
REPEAT = 7
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
print("PY", sys.version.split()[0], f"| лучшее из {REPEAT} вызовов gc.collect()")
print()
print(" живых списков gc.collect() на объект")
gc.collect()
base = best_collect()
rows = []
for n in (10_000, 100_000, 1_000_000):
live = [[i] for i in range(n)]
assert gc.is_tracked(live[0])
t = best_collect()
rows.append((n, t))
print(f" {n:>13,} {t * 1e3:9.2f} мс {(t - base) / n * 1e9:6.1f} нс".replace(",", " "))
del live
gc.collect()
print(f" {'пустая куча':>13} {base * 1e3:9.2f} мс")
print()
(_, t_small), _, (_, t_big) = rows
print(f" отношение времени сборки, миллион к десяти тысячам: {t_big / t_small:.0f}")
print()
live = [[i] for i in range(1_000_000)]
before = best_collect()
gc.freeze()
after = best_collect()
print(" тот же миллион живых списков")
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()