Объект живёт, пока на него смотрят. Из этой одной фразы выводится и то, почему with в Python действительно закрывает файл, и то, почему в Python есть GIL. Два самых обсуждаемых свойства языка — следствия одного решения.
Зафиксируй вывод и причину до того, как откроешь ответ.
class Res:
def __init__(self, n): self.n = n
def __del__(self): print(f"закрыт {self.n}")
def work():
r = Res("A")
print("работаем")
work()
print("после work")
работаем
закрыт A
после workВопрос не «что напечатается», а в какой момент — и почему именно в этот.import sys
x = []
print(sys.getrefcount(x))
def f(o):
return sys.getrefcount(o)
print(f(x))
2
3Имя одно, а счётчик показывает два. Потом три.for line in open("data.txt"):
process(line)
# в этой точке файл закрыт?
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")
после delИ всё. Оба объекта недостижимы, но __del__ не вызван ни разу. Это единственный фрагмент, который механизм этого урока объяснить не может — им занимается следующий.У каждого объекта есть счётчик ссылок. Он лежит в заголовке — те самые первые восемь байт из урока 01.
Счётчик растёт, когда появляется новая ссылка, и падает, когда ссылка исчезает. Ссылку создают: имя, элемент контейнера, атрибут другого объекта, аргумент вызова, промежуточное значение на стеке интерпретатора.
Когда счётчик достигает нуля, объект уничтожается немедленно. Не «когда-нибудь», не «при следующей сборке» — прямо в этой точке, синхронно.
Фрагмент A. Пока функция работает, на объект смотрит локальная переменная r — точнее, слот фрейма. Когда work возвращается, фрейм уничтожается, все его слоты освобождают свои ссылки, счётчик Res("A") падает до нуля, и деструктор вызывается внутри возврата из функции — до того, как выполнится следующая строка вызывающего кода.
Отсюда сразу два вывода. Первый: with в Python работает не потому, что кто-то аккуратно расставил __exit__, а потому что момент смерти объекта известен точно. Второй, важнее: это свойство реализации, а не языка 🔧. Спецификация обещает, что объект будет уничтожен, и не обещает когда. Фрагмент C — ровно про это: код, который «всегда работал», на другой реализации начинает копить открытые дескрипторы. Поэтому with — не вопрос стиля.
Фрагмент B перестаёт быть загадкой, если помнить, что аргумент функции — тоже ссылка. Имя x даёт единицу, параметр getrefcount внутри вызова — вторую. Отсюда двойка. Во втором случае добавляется ещё и параметр o функции f, отсюда тройка.
Это не курьёз замера, а точное описание работы механизма: каждая передача объекта куда угодно — это инкремент, каждый выход из области — декремент. Счётчик двигается постоянно и на каждый чих.
Здесь стоит остановиться и вывести самому. Счётчик — обычное целое в памяти. Два потока одновременно освобождают ссылки на один объект. Оба читают значение 2, оба вычитают единицу, оба записывают 1. Одно обновление потеряно, счётчик никогда не дойдёт до нуля — утечка. Хуже симметричный случай: счётчик уходит в ноль раньше времени, объект разрушается под ногами у живой ссылки, и дальше — обращение к освобождённой памяти.
Перечисли варианты, прежде чем читать дальше.
CPython выбрал третье. GIL — не отдельное решение и не недоработка. Это цена детерминированного разрушения. И теперь понятно, почему убрать его так тяжело: счётчик ссылок торчит в публичном C-API, и на предположение «меня никто не трогает параллельно» опираются тысячи расширений.
Фрагмент D. a.peer = b и b.peer = a — это две ссылки, живущие внутри самих объектов. После del a, b внешних ссылок не осталось, но счётчик каждого равен единице: их держит сосед. Ноль недостижим, память не освобождается никогда.
Счётчик по своей природе локален — он знает только «сколько на меня смотрят» и не знает «достижим ли я откуда-то снаружи». Значит нужен второй механизм, работающий с достижимостью, а не с количеством. Ему посвящён следующий урок.
Всё это — про CPython 🔧. PyPy, GraalPy и Jython используют трассирующую сборку и счётчика не имеют вовсе. Код, полагающийся на момент разрушения, там ведёт себя иначе — и это не считается их багом.
Часть объектов бессмертна. Начиная с 3.12 None, True, False, мелкие целые и интернированные строки имеют фиксированный счётчик, который не двигается вообще 🔧. Сделано это ради free-threading: у таких объектов больше нет горячей точки конкуренции. На 3.11 и раньше их счётчики обычные, поэтому sys.getrefcount(None) на разных версиях покажет принципиально разное.
getrefcount нельзя использовать для логики. Он всегда завышен минимум на единицу и зависит от того, сколько временных ссылок держит интерпретатор в этот момент. Как инструмент отладки — годится; как условие в коде — никогда.
Решение: временем жизни объекта управляет счётчик ссылок. Выбрано оно в самом начале, в 1990 году, и по причинам, которые тогда выглядели бесспорно.
Счётчик прост: сотня строк, никаких обходов графа, никаких пауз. Он предсказуем: программа не замирает в случайный момент. И, что оказалось важнее всего, он прозрачен для C — расширение может владеть объектом, ничего не зная о сборщике: увеличило счётчик, попользовалось, уменьшило. Именно это свойство сделало возможным весь научный стек — и оно же двадцать лет спустя стало главной причиной, почему GIL нельзя просто удалить.
Цена выяснялась постепенно. Сначала обнаружились циклы — понадобился второй механизм. Потом многопоточность — понадобился глобальный лок. Ни то, ни другое не было очевидно в 1990-м, когда многоядерных машин у обычного разработчика не существовало.
Это std::shared_ptr, и аналогия здесь не приблизительная, а точная: счётчик в объекте, инкремент при копировании владельца, разрушение при обнулении. Совпадает даже болезнь — циклические ссылки в C++ точно так же не собираются, и лечатся точно так же, weak_ptr против weakref.
Если ты работал с PyTorch, у тебя есть и второе знакомое проявление. Каждый тензор с requires_grad держит ссылки на узлы графа вычислений, а те — на входные тензоры. Пока жива ссылка на loss, жив весь граф до самого входа. Это не особенность фреймворка — это ровно следствие 1 в действии, и оно объясняет самую частую причину OOM в цикле обучения.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
Потому что в 1990 году счётчик был единственным способом получить детерминированное разрушение бесплатно, а Java решала другую задачу.
Счётчик даёт то, чего трассирующий сборщик не даёт принципиально: объект умирает в тот момент, когда на него перестали смотреть. Отсюда работающий with, отсюда привычка писать open(path).read(), отсюда предсказуемая память в долгоживущем процессе — она не растёт пилой между сборками.
Цена оказалась огромной, и её платят до сих пор:
Java выбрала наоборот: только трассирующий сборщик, никакого детерминизма (finalize объявлен ненадёжным и в итоге удалён), зато многопоточность без глобального лока и поколенческий аллокатор, где выделение объекта — сдвиг указателя. За детерминизм там отвечает try-with-resources — то есть то же самое решение, что with, только обязательное.
Вывод, который стоит унести: это не «Python выбрал хуже». Это два разных ответа на вопрос «кто отвечает за освобождение ресурса». Python сделал ставку на то, что счётчик справится сам; Java — на то, что программист напишет блок явно. Спустя тридцать лет Python пришёл к тому же with, а ставка на счётчик осталась висеть в виде GIL.
sys.getrefcount всегда возвращает на единицу больше?Потому что передача аргумента — это тоже ссылка.
import sys
x = object()
print(sys.getrefcount(x)) # → 2
Ссылок две: имя x и параметр внутри getrefcount, который существует, пока функция работает. Ничего специфического в самой функции нет — так ведёт себя любой вызов, просто здесь это видно.
Отсюда практика: интересует изменение, а не абсолютная величина.
import sys
data = []
before = sys.getrefcount(data)
holder = {"k": data}
print(sys.getrefcount(data) - before) # → 1 словарь добавил ссылку
Осторожно с интерпретацией. Числа для маленьких целых, коротких строк и None ничего не значат — эти объекты закешированы и на них смотрит весь процесс. Проверять счётчик имеет смысл только для собственных объектов, и лучше не счётчиком, а weakref: он отвечает на прямой вопрос «жив или нет» и не зависит от деталей подсчёта.
import weakref, gc
class T: pass
t = T(); r = weakref.ref(t)
del t; gc.collect()
print(r()) # → None объект действительно умерВ своём коде — нет. В понимании чужого — обязательно.
Эти две вещи регулярно смешивают. Правило для кода простое: если ресурс важен, освобождать его должен with. Файл, сокет, соединение с базой, блокировка, временный каталог. Это одна строка, она работает на любой реализации, и она документирует намерение.
# работает на CPython, копит дескрипторы на PyPy
data = open("f.txt").read()
# работает везде и читается лучше
with open("f.txt") as fh:
data = fh.read()
Зачем тогда знать про счётчик. Затем, что половина написанного Python-кода на него опирается, и это придётся читать. Когда сервис на PyPy падает с «too many open files», а тот же код на CPython работал годами, — объяснение только здесь. Когда объект не умирает после del, потому что попал в цикл или в чей-то кеш, — тоже.
Это и есть тот случай, ради которого в каноне заведена шкала твёрдости. 🔧 означает не «не используй», а «работает, но не является обещанием». Разница видна ровно тогда, когда меняется реализация или версия — то есть в самый неудобный момент.
with, который при этом является деталью CPython, а не гарантией языка. Отсюда же завышенный на единицу getrefcount: аргумент вызова — такая же ссылка. И отсюда GIL: счётчик неатомарен, а из трёх способов это починить глобальный лок был самым дешёвым и единственным совместимым с уже написанными C-расширениями. Чего механизм не умеет — видеть циклы: локальный счётчик не знает о достижимости, и для неё нужен второй механизм.
Java, Go и JavaScript выбрали трассирующую сборку и не платят за детерминизм. У них нет глобального лока, потоки работают параллельно по-настоящему. Но момент разрушения неизвестен, и поэтому в каждом из этих языков пришлось изобрести отдельную конструкцию для освобождения ресурсов: try-with-resources в Java, defer в Go, using в C#. Python получил ту же гарантию бесплатно, как побочный эффект счётчика.
Swift — самый интересный случай: он выбрал ровно то же решение, только вставляет операции со счётчиком на этапе компиляции. И получил ровно ту же проблему циклов. Только решил её иначе: второго механизма нет, циклы — ответственность программиста, который обязан расставить weak и unowned руками. Python предпочёл доплатить сборщиком и не перекладывать это на человека.
Три языка, одна развилка, три разные цены. Ни один вариант не бесплатен.
Цикл обучения копит метрики. Строка выглядит безобидно и убивает процесс по памяти на середине эпохи.
losses = []
for batch in loader:
loss = model(batch)
loss.backward()
losses.append(loss) # ← вот здесь
print(sum(losses) / len(losses))
Ревью видит: собираем лоссы, считаем среднее. Всё логично. Но loss — не число, а тензор, держащий ссылку на граф вычислений, а тот — на активации всего батча. Счётчик каждого узла графа не дойдёт до нуля, пока список жив. Через тысячу шагов в памяти лежит тысяча графов.
Тот же эффект без всякого ML — цепочка ссылок между объектами, из которых наружу торчит только последний:
| что держим | удержано памяти |
|---|---|
| объекты целиком (каждый ссылается на предыдущий) | 20.0 МБ |
| только нужные значения | 0.11 МБ |
Разница в сто восемьдесят раз, и вызвана она одной ссылкой в каждом объекте. Лечится тем же, чем и в ML: сохранять значение, а не объект — losses.append(loss.item()). Общее правило выводится прямо из механизма: если в долгоживущий контейнер кладётся объект, вместе с ним удерживается весь достижимый из него граф. Клади наружу минимум.
«Память растёт, кто держит объект — непонятно». На это уходят дни, хотя механизм отвечает за минуту.
Счётчик знает, сколько ссылок. Сборщик знает, кто их держит, и умеет это показать:
import gc, sys
print(sys.getrefcount(model))
# → 3
for r in gc.get_referrers(model):
print(type(r).__name__, str(r)[:60])
# → dict {140234...: <__main__.Model object at 0x7f53...>}
# → dict {'__name__': '__main__', ...}
Первый же держатель — словарь, где ключом стоит id. Это чей-то реестр, который забыли чистить. Дальше остаётся найти, кто этот словарь создал, и вопрос закрыт.
Порядок действий, который работает почти всегда: посмотреть getrefcount и убедиться, что держателей действительно больше ожидаемого; получить их список через get_referrers; если держатель — контейнер, повторить для него же. Для агрегированной картины по всему процессу — tracemalloc со снимками до и после и сравнением по строкам кода.
Оговорка: get_referrers видит только объекты, которые отслеживает сборщик, то есть контейнеры. Ссылку из C-расширения он не покажет — и это само по себе диагноз, сужающий поиск.
Прогони четыре фрагмента, сверяясь с предсказанием. Затем — вопросы, ответы на которые выводятся из механизма:
import sys
sys.getrefcount(None)
# Почему число огромное? А на 3.12 — почему другое?
def f():
x = SomeObject()
return 1 / 0
# Будет ли вызван деструктор x при вылете исключения? Почему?
a = []
a.append(a)
del a
# Что сейчас произошло с памятью?
# И вопрос без кода: почему сборка на PyPy делает
# free-threading ненужной проблемой?
Второй вопрос — самый практичный: от ответа зависит, надёжен ли with при исключениях, и почему именно.
Lib/_weakrefset.py — сто строк, реализующие множество, которое не удерживает свои элементы. Само по себе это простая обёртка, но интересно, зачем она в стандартной библиотеке.
Её главный потребитель — ABCMeta. Когда класс регистрируется как реализация абстрактного базового класса, регистр обязан его запомнить — и обязан не мешать ему быть выгруженным. Обычное множество превратило бы каждую регистрацию в вечную ссылку, и ни один такой класс никогда не был бы собран. Загляни в Lib/abc.py и найди _abc_registry: механизм этого урока там виден как проектное ограничение, а не как теория.
Заметь ещё одну деталь: у WeakSet есть колбэк, который вызывается при смерти элемента и вычищает его из множества. Слабая ссылка не просто «не держит» — она умеет сообщить о смерти. Это и делает возможными самоочищающиеся кэши.
Из L1 ты знаешь, что счётчик есть у каждого объекта. Здесь — где именно и во что обходится.
Заголовок объекта начинается с поля счётчика, и это первые восемь байт по адресу объекта. Значит его можно прочитать без всякого sys:
import ctypes
x = [1, 2, 3]; y = x
print(ctypes.c_ssize_t.from_address(id(x)).value)
# → 2
Два — x и y, без завышения, потому что ctypes получает не сам объект, а его адрес числом. Именно поэтому этот способ точнее getrefcount для отладки.
В исходниках операции выглядят как макросы Py_INCREF и Py_DECREF: инкремент — одна инструкция, декремент — инструкция плюс проверка на ноль плюс, при нуле, вызов деструктора типа. Дёшево поштучно и заметно в сумме: интерпретатор выполняет их на каждую передачу объекта, включая все промежуточные значения при вычислении выражений.
Отсюда практическое следствие, объясняющее старый приём: вынести self.method или obj.attr в локальную переменную перед горячим циклом выгодно не только из-за поиска атрибута (урок 04), но и потому, что исчезает пара инкремент-декремент на каждой итерации.
И отсюда же понятно устройство free-threading в 3.13: счётчик там разделён на локальный для потока-владельца и разделяемый для всех остальных, чтобы в типичном случае — объект используется тем же потоком, который его создал — инкремент оставался обычной неатомарной операцией. Плюс бессмертные объекты, у которых счётчик не двигается вовсе. Обе меры существуют ровно потому, что следствие 2 делает счётчик самой горячей точкой интерпретатора.
Тег v3.11.15.
Include/object.h — поле ob_refcnt и макросы Py_INCREF / Py_DECREF. Двадцать строк, из которых растёт весь этот урок.Objects/object.c, _Py_Dealloc — что происходит в момент обнуления: вызов tp_dealloc типа.Python/ceval_gil.h — реализация самого GIL: мьютекс, условная переменная, gil_drop_request и интервал переключения. Файл небольшой, и после следствия 3 читается совсем иначе.weakref — хватит разбора выше; идти туда, когда понадобится колбэк на смерть объекта.