Второй механизм — тот, что доделывает работу за счётчиком. Из него выводится, почему __del__ ненадёжен, почему отключение сборщика иногда ускоряет код в девять раз и почему память, которую вы освободили, не вернулась операционной системе.
import gc
class Node:
def __init__(self, n): self.n = n; self.peer = None
def __del__(self): print(f"удалён {self.n}")
a = Node(1); b = Node(2)
a.peer = b; b.peer = a
del a, b
print("после del")
print("collect вернул:", gc.collect())
после del
удалён 1
удалён 2
collect вернул: 2Деструкторы всё-таки вызвались — но не тогда, когда объекты стали недостижимы.import gc
print(gc.get_threshold())
print(gc.get_count())
(700, 10, 10)
(13, 0, 0)Три числа и три числа. Что именно считается и почему пороги такие разные?xs = [[i] for i in range(2_000_000)] # заняли память
del xs[::2] # удалили половину, через одну
gc.collect()
# сколько памяти вернулось процессу?
пик 238 МБ
после 232 МБУдалили миллион объектов из двух — освободилось шесть мегабайт.import gc, time
def build():
t = time.perf_counter()
data = [{"i": i, "p": None} for i in range(1_500_000)]
return time.perf_counter() - t
gc.enable(); print(build())
gc.disable(); print(build())
2.10
0.23Одна строка настройки — код быстрее в девять раз. Причём ни один объект здесь не образует цикла.Прошлый урок закончился на дыре: счётчик локален и не знает о достижимости, поэтому цикл ссылок для него неотличим от живого объекта. Значит нужен механизм, который работает с достижимостью. Он есть, и устроен минимально — ровно чтобы закрыть эту дыру, и ни на шаг больше.
Сборщик отслеживает только контейнеры — объекты, которые в принципе способны ссылаться на другие. Число, строка, bytes не отслеживаются вовсе: они не могут участвовать в цикле.
Он ищет группы, достижимые только изнутри себя. Для каждого отслеживаемого объекта берётся его счётчик, из него вычитаются ссылки, приходящие от других объектов группы. Остаток больше нуля — на объект смотрят снаружи, он жив вместе со всем, до чего дотягивается. Остаток нулевой у всей группы — группа мусор.
Он делит объекты на три поколения. Новый объект попадает в нулевое; переживший сборку переходит в следующее. Нулевое поколение проверяется часто, второе — редко.
Фрагмент A. В момент del a, b ничего не происходит: счётчики обоих узлов равны единице, их держит сосед. Объекты становятся мусором, но никто об этом не знает. Знание появляется только когда сборщик пройдёт по нулевому поколению — по расписанию или по явному gc.collect().
Отсюда главный практический вывод про __del__: момент его вызова не определён, если объект участвует в цикле. А участвует он часто и незаметно — родитель со списком детей, у каждого ребёнка ссылка на родителя; замыкание, захватившее объект, который его хранит; исключение, чей traceback ссылается на фрейм, в котором лежит само исключение.
Историческая деталь, которая объясняет чужие советы: до 3.4 сборщик вообще отказывался собирать циклы, содержащие объект с __del__, — он не мог выбрать порядок финализации и складывал такие группы в gc.garbage, то есть в вечную утечку. PEP 442 это исправил, и сегодня циклы с деструкторами собираются нормально. Но правило «не полагайся на __del__» пережило причину и осталось верным по другой причине — по этой.
Фрагмент B: (700, 10, 10) — это не «каждые 700 объектов». Первое число — превышение числа созданных объектов над числом уничтоженных, при котором запускается обход нулевого поколения. Второе и третье — сколько сборок младшего поколения должно пройти, чтобы обойти следующее.
Дальше следует арифметика, объясняющая фрагмент D. Пока код в цикле создаёт полтора миллиона словарей и ничего не удаляет, разница «создано минус удалено» растёт непрерывно. Каждые семьсот объектов запускается обход нулевого поколения; каждые десять таких обходов — первого; каждые десять тех — второго. А обход второго поколения — это проход по всем живым отслеживаемым объектам, которых к тому моменту уже миллион.
Получается патология: чем больше долгоживущих объектов накопила программа, тем дороже каждая полная сборка, и запускается она ровно в фазе, когда объекты активно создаются. Девятикратная разница в замере — это она.
| 1.5 млн словарей | время |
|---|---|
| сборщик включён | 2.10 с |
| сборщик отключён | 0.23 с |
Обрати внимание, что циклов в этом коде нет вообще. Программа платит за механизм, которым не пользуется, — и это не ошибка реализации, а неизбежность: узнать, есть ли циклы, можно только пройдя по объектам.
Фрагмент C — про третий уровень, аллокатор. Питон не ходит к операционной системе за каждым объектом. Он берёт у неё крупные куски — арены, режет их на пулы одинакового размера, а пулы — на блоки по классам размера с шагом восемь байт. Объект меньше порога в пол-килобайта получает блок; всё крупнее уходит напрямую системному malloc.
Когда объект умирает, его блок возвращается в пул и немедленно доступен для следующего объекта такого же размера. Операционной системе при этом не возвращается ничего. Арена отдаётся обратно, только когда становится полностью пустой.
Теперь фрагмент C очевиден: мы удалили каждый второй объект, то есть в каждой арене осталась половина живых. Ни одна арена не опустела, ни одна не вернулась — RSS упал на шесть мегабайт из двухсот тридцати восьми. Это и есть фрагментация, и наблюдается она в любом долгоживущем процессе.
Отсюда важное следствие для чтения метрик: RSS не является мерой утечки. Не упавшая после чистки память может означать и утечку, и обычную фрагментацию, и это два разных диагноза с разным лечением.
Раз проблема в том, что ссылка удерживает объект, нужен способ сослаться, не удерживая. weakref — ровно это: указатель, не увеличивающий счётчик. Объект умирает по своим правилам, слабая ссылка после этого возвращает None, а если попросить — вызывает колбэк в момент смерти.
Отсюда выводятся и правильные применения: реестры, наблюдатели, кэши, обратные ссылки «ребёнок → родитель». Всё, что должно знать об объекте, но не должно решать, когда он умрёт.
Не всё отслеживается. Кортеж, состоящий только из неизменяемых атомарных объектов, со временем снимается с наблюдения — участвовать в цикле он не может. Поэтому gc.get_referrers не покажет некоторые пути, а «объектов в gc.get_objects()» всегда меньше, чем объектов в процессе.
Отключение сборщика — не бесплатный ускоритель. Оно корректно только там, где циклов заведомо не образуется, или где после фазы будет явный gc.collect(). В веб-приложении с ORM циклы образуются постоянно, и отключённый сборщик превращается в медленную утечку.
Финализация при завершении интерпретатора не определена 🕳. Порядок разрушения на выходе не гарантирован, часть объектов может не быть финализирована вовсе, а модули к этому моменту уже частично разобраны — поэтому __del__, обращающийся к глобальным именам, на выходе иногда падает с загадочными AttributeError.
Числа поколений и порогов — деталь реализации 🔧. В 3.12 схему поколений переработали; сама идея «молодые проверяются чаще» осталась, конкретные цифры — нет.
Корень тот же, что в прошлом уроке: временем жизни управляет счётчик ссылок. Всё, что разобрано здесь, — не самостоятельное решение, а заплатка на известный дефект счётчика, и понимать это важно.
Сборщик циклов появился в Python 2.0, спустя десять лет после самого языка. Десять лет циклические ссылки просто текли. Когда его добавляли, ключевым требованием была совместимость: тысячи существующих C-расширений манипулировали счётчиком напрямую и ничего не знали ни о каком обходе графа. Поэтому сборщик сделан дополнением — он не заменяет счётчик и не трогает объекты, о которых ему не сообщили.
Отсюда честная оценка цены: Python платит за обе схемы сразу. За счётчик — операциями на каждую передачу объекта и глобальным локом. За сборщик — периодическими обходами всех живых объектов. Языки с трассирующей сборкой платят только за вторую половину.
Если работал с PyTorch на GPU — устройство аллокатора уже знакомо. Кэширующий аллокатор CUDA берёт у драйвера крупные блоки, режет их под тензоры и не возвращает драйверу при освобождении тензора. Именно поэтому nvidia-smi показывает занятую память после того, как всё удалено, и именно поэтому существует torch.cuda.empty_cache().
Это тот же приём и та же проблема: работа с системой дорогая, поэтому память кэшируется пулами; пул отдаётся, только когда пуст; при неудачной раскладке живых блоков не отдаётся ничего. Разница лишь в том, что у PyTorch есть кнопка принудительного возврата, а у CPython её нет — арена освобождается сама или не освобождается.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
Потому что «освободить объект» и «вернуть память ОС» — два разных события, и второе происходит гораздо реже.
CPython выделяет память аренами по 1 МБ (в 3.11 — 1 МБ, раньше 256 КБ), нарезает их на пулы, пулы — на блоки под мелкие объекты. Арена возвращается системе, только когда полностью пуста. Один живой объект в арене удерживает весь мегабайт.
import gc
big = [object() for _ in range(1_000_000)]
del big[1:] # оставили ровно один объект
gc.collect()
# RSS почти не изменится: живые объекты рассыпаны по аренам
Это классическая фрагментация, и она не баг: альтернатива — перемещать объекты, а перемещать их нельзя, потому что C-расширения держат на них сырые указатели (урок 15). Компактящий сборщик из Java здесь невозможен по той же причине, по которой невозможно убрать GIL.
Что из этого следует практически. Растущий RSS не доказывает утечку, а стабильный не доказывает её отсутствие. Мерить надо не RSS, а количество и типы живых объектов — tracemalloc, gc.get_objects(), снимки до и после. И если пик памяти разовый (загрузили, обработали, выбросили) — самый надёжный способ вернуть память системе это отработать в отдельном процессе и завершить его.
Потому что проверка всего — это остановка мира на время, пропорциональное числу живых объектов, а их в среднем процессе миллионы.
В основе — эмпирическое наблюдение, верное почти во всех языках: подавляющее большинство объектов умирает молодыми. Временный список внутри функции, строка из форматирования, кортеж-аргумент. Долгоживущих — конфиг, кеши, модули — мало, и проверять их с той же частотой бессмысленно.
Отсюда три поколения и пороги (700, 10, 10): нулевое проверяется после каждых 700 «лишних» аллокаций, первое — после каждых десяти проходов нулевого, второе — после десяти проходов первого. Объект, переживший проверку, повышается в звании.
import gc
print(gc.get_threshold()) # → (700, 10, 10)
print(gc.get_count()) # текущие счётчики
print(gc.get_stats()[2]) # что делало старшее поколение
Практическое следствие, ради которого это стоит знать: при массовой загрузке данных в память создаются сотни тысяч долгоживущих объектов, и каждый проход старшего поколения обходит их все — впустую, потому что они живые. Замер из этого урока: gc.disable() на время загрузки дал девятикратное ускорение.
Схему в 3.12 переработали: идея «молодые проверяются чаще» осталась, конкретные пороги — нет 🔧.
gc.disable() — когда это безопасно?Когда ты понимаешь, что именно отключаешь: не сборку мусора, а только поиск циклов.
Счётчик ссылок продолжает работать всегда, его отключить нельзя. gc.disable() останавливает лишь периодический обход поколений — то есть всё, что не в циклах, освобождается как обычно и немедленно.
Безопасно: на время фазы, где создаётся много долгоживущих объектов без циклов — загрузка датасета, парсинг большого файла, прогрев кеша. Классический приём:
import gc
gc.disable()
try:
data = load_everything() # сотни тысяч объектов
finally:
gc.enable()
gc.collect() # один раз честно собрать
Опасно: на постоянной основе в сервисе. Циклы образуются гораздо чаще, чем кажется: любой parent ↔ child, любое исключение, чья трассировка ссылается на фрейм, любой объект с методом-обратным-вызовом на себя. Без обхода они не освободятся никогда, и память будет расти монотонно.
Промежуточный вариант, который часто лучше обоих: оставить сборщик включённым, но поднять пороги — gc.set_threshold(50_000, 20, 20). Обходы станут редкими, но не исчезнут. Ещё один приём для форк-серверов — gc.freeze() после загрузки: переносит текущие объекты в постоянное поколение, и они перестают участвовать в обходах вообще.
__del__ считается ненадёжным, если счётчик работает?Потому что у него четыре разных способа не сработать, и три из них от программиста не зависят.
Первое — момент не гарантирован. Объект в цикле умрёт не при потере ссылки, а на следующем обходе поколений. Когда он будет — неизвестно.
Второе — выход из интерпретатора. На завершении модули разбираются, глобальные обнуляются, и __del__ может не вызваться вовсе, а если вызовется — обнаружит, что нужные ему имена уже None 🕳.
Третье — исключения проглатываются. Ошибка внутри __del__ не всплывает: её печатают в stderr и продолжают, потому что бросать неоткуда — нет контекста вызова.
class T:
def __del__(self): raise ValueError("важная ошибка")
t = T(); del t
print("программа продолжается")
# → сообщение в stderr, исключение потеряно
Четвёртое — воскрешение. __del__ может сохранить self куда-нибудь, и объект оживёт. С 3.4 (PEP 442) финализатор для одного объекта вызывается не более одного раза, и повторной смерти уже не будет.
Что использовать вместо. with — когда область жизни ресурса видна в коде. weakref.finalize — когда не видна: он вызывается надёжнее, регистрируется явно и не удерживает объект. atexit — для процессных ресурсов. __del__ имеет смысл оставлять только как последний рубеж с предупреждением в лог: «этот объект закрыли не через with».
__del__ ненадёжен; сборка стоит времени, пропорционального числу живых объектов, поэтому в фазе массового создания отключение сборщика даёт девятикратное ускорение; освобождение объекта и возврат памяти системе — разные события, поэтому не упавший RSS не является доказательством утечки. Python платит за обе схемы управления памятью сразу — это цена детерминированного разрушения и совместимости с экосистемой, которая без него не возникла бы.
Java использует поколения как основной механизм, а не как дополнение. Гипотеза та же — большинство объектов умирает молодыми, — но из неё выжато всё: копирующий сборщик молодого поколения делает выделение памяти почти бесплатным, а уплотнение решает проблему фрагментации, которой у Python нет решения вовсе. Цена — паузы и невозможность знать момент разрушения.
Go выбрал конкурентный сборщик с упором на короткие паузы и заплатил пропускной способностью. Rust вообще отказался от сборки: владение проверяется компилятором, циклы требуют Rc с Weak вручную — то есть та же проблема, снова переложенная на программиста, как и в Swift.
Общий вывод: механизма без цены нет, и Python — единственный из перечисленных, кто платит дважды. Взамен он получил детерминированное разрушение и совместимость с гигантской экосистемой C-расширений, которая без этого решения просто не возникла бы.
«У нас утечка: почистили кэш наполовину, память не вернулась». Утечки нет.
xs = [[i] for i in range(2_000_000)]
del xs[::2] # выбросили половину записей
gc.collect()
| момент | RSS |
|---|---|
| старт | 8 МБ |
| пик | 238 МБ |
| после удаления половины | 232 МБ |
| после удаления всего | 11 МБ |
Миллион объектов из двух миллионов освобождён, RSS упал на шесть мегабайт. Выглядит как утечка ровно до тех пор, пока не удалить всё — и тогда память возвращается почти полностью. Значит объекты собирались нормально, а дело в аренах: в каждой осталась половина живых, ни одна не опустела.
Почему это опасно: команда, увидев такой график, начинает искать несуществующую утечку, добавляет gc.collect() в горячий путь (что не помогает — сборщик не занимается возвратом памяти системе) и в итоге перезапускает воркеров по таймеру. Настоящее лечение другое: не удалять вразбивку из долгоживущей структуры, а пересоздавать её целиком, либо держать однородные данные вне объектной модели — в array, буфере или numpy, где миллион чисел лежит одним куском.
Отличить одно от другого просто: снимок tracemalloc до и после покажет, растёт ли число живых объектов. Растёт — утечка. Не растёт, а RSS высокий — фрагментация.
Фаза массовой загрузки данных ускоряется отключением сборщика — но только если понимаешь, за что платишь.
import gc
gc.disable()
records = load_everything() # миллионы объектов, ничего не удаляется
gc.enable()
gc.collect() # один раз, осознанно
| 1.5 млн объектов | время |
|---|---|
| сборщик включён | 2.10 с |
| сборщик отключён | 0.23 с |
Девятикратный выигрыш берётся из следствия 2: в фазе, где объекты только создаются, разница «создано минус удалено» растёт непрерывно, обходы запускаются постоянно, и каждый следующий дороже предыдущего, потому что живых объектов всё больше. Сборщик ищет циклы, которых нет, — и делает это тем усерднее, чем успешнее идёт загрузка.
Когда это законно. Фаза ограничена по времени; в ней преимущественно создают, а не удаляют; после неё стоит явный collect. Загрузка датасета, парсинг большого файла, прогрев кэша при старте.
Когда это выстрел в ногу. Долгоживущий обработчик запросов, ORM, любой код, где объекты и создаются, и умирают, и ссылаются друг на друга. Там отключённый сборщик — это медленно растущая утечка, которую заметят через неделю.
Родственный приём для форкающихся серверов — gc.freeze() перед форком: он не отключает сборку, а выводит из-под неё всё, что заведомо переживёт процесс. Дешевле и безопаснее полного отключения.
Прогони четыре фрагмента, сверяясь с предсказанием. Затем:
import gc
class A:
def __init__(self): self.me = self
for _ in range(100_000): A()
gc.collect()
# Сколько вернёт? А если убрать self.me?
try:
raise ValueError("x")
except ValueError as e:
pass
# Почему Python сам удаляет имя e после блока except?
# Какой цикл он этим разрывает?
t = (1, 2, 3)
gc.is_tracked(t)
t2 = (1, [2], 3)
gc.is_tracked(t2)
# Почему ответы разные и что это экономит?
Второй вопрос — самый красивый: удаление e после except прописано в языке специально 🔒, и причина ровно в механизме этого урока.
Модуль gc в чужом коде — лучший способ увидеть, кто и зачем на самом деле трогает сборщик. Поищи gc.freeze(): он появился в 3.7 по конкретному запросу и решает конкретную задачу. Веб-приложение загружает весь код и данные, а затем форкается на воркеров. При копировании при записи страницы должны оставаться общими — но сборщик, проходя по объектам, трогает их заголовки, страницы становятся приватными, и потребление памяти растёт линейно по числу воркеров. gc.freeze() переносит всё созданное до форка в неприкосновенное поколение.
Обрати внимание на форму этого решения: это не оптимизация сборщика, а способ его выключить для конкретных объектов — потому что автоматически различить «данные, живущие вечно» и «мусор» механизм не в состоянии.
Второе место — Lib/weakref.py, класс WeakValueDictionary. Заметь, как он устроен: значения хранятся не напрямую, а обёрнутыми в слабые ссылки с колбэком, который удаляет запись при смерти объекта. Самоочищающийся кэш из следствия 4 в готовом виде.
Из L1 ты знаешь, что сборщик ищет группы, достижимые только изнутри. Здесь — как именно, и что можно измерить.
Алгоритм умещается в четыре шага. Для поколения, которое собирают, каждому объекту заводится временная копия счётчика. Затем сборщик обходит все объекты поколения и спрашивает у каждого его ссылки — для этого у типа есть слот обхода tp_traverse, — и за каждую ссылку на объект внутри того же поколения уменьшает временный счётчик цели. После обхода временный счётчик показывает число ссылок снаружи. Всё, у чего он больше нуля, — корни; от них идёт поиск в глубину, и всё достижимое помечается живым. Оставшееся непомеченным — циклический мусор, он финализируется и освобождается.
Отсюда видно, почему сборщик работает только с контейнерами: если у типа нет tp_traverse, он невидим для обхода. И почему написать корректное C-расширение с контейнером трудно: забытый tp_traverse даёт не падение, а тихую утечку циклов.
Что можно посмотреть:
print(gc.get_stats()[2]) # статистика по старшему поколению
# → {'collections': 3, 'collected': 118, 'uncollectable': 0}
gc.set_debug(gc.DEBUG_STATS) # печатать время каждой сборки
len(gc.get_objects()) # сколько объектов под наблюдением
collections по старшему поколению — самая полезная метрика: если она растёт в горячем пути, значит сборщик регулярно обходит все живые объекты, и стоит посмотреть на пороги через gc.set_threshold. Практика для сервисов с большим постоянным набором объектов — увеличить первый порог в несколько раз: полные обходы становятся реже, а находят они ровно столько же.
Про аллокатор из следствия 3 измеримы две вещи: sys._debugmallocstats() печатает раскладку по классам размера и число арен, а разница между суммой tracemalloc и RSS процесса и есть та самая фрагментация плюс накладные расходы.
Тег v3.11.15.
Modules/gcmodule.c, функция subtract_refs — те самые вычитания внутренних ссылок, шаг за шагом. Дальше move_unreachable — разделение на живых и мусор.Objects/obmalloc.c — аллокатор целиком. В шапке файла лежит большой комментарий с картой арен, пулов и блоков; он лучше любого пересказа, включая этот.__del__ были вечной утечкой.Objects/typeobject.c, поиск по tp_traverse — как тип сообщает сборщику о своих ссылках.Rc с Weak в Rust и ARC в Swift: второго механизма нет, циклы — работа программистаtp_traverse и почему написать корректный контейнер на C трудноdict, чья раскладка определяет большую часть аллокаций, — в L05main, поэтому описывает уже пост-3.12 схему поколений — расхождение с числами этой главы ожидаемо и само по себе показательно.gc — выборочно: freeze, get_stats, set_threshold. Остальное — справочник.