Подсчёт ссылок

Объект живёт, пока на него смотрят. Из этой одной фразы выводится и то, почему with в Python действительно закрывает файл, и то, почему в Python есть GIL. Два самых обсуждаемых свойства языка — следствия одного решения.

L1 · 8 минL2 · 12 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
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
Вопрос не «что напечатается», а в какой момент — и почему именно в этот.
Фрагмент B
import sys
x = []
print(sys.getrefcount(x))

def f(o):
    return sys.getrefcount(o)
print(f(x))
2
3
Имя одно, а счётчик показывает два. Потом три.
Фрагмент C
for line in open("data.txt"):
    process(line)

# в этой точке файл закрыт?
В CPython — да, сразу после цикла. На PyPy — когда-нибудь. Один и тот же код, разный ответ, и это не баг ни одной из реализаций.
Фрагмент D
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__ не вызван ни разу. Это единственный фрагмент, который механизм этого урока объяснить не может — им занимается следующий.

2Механизм

У каждого объекта есть счётчик ссылок. Он лежит в заголовке — те самые первые восемь байт из урока 01.

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

Когда счётчик достигает нуля, объект уничтожается немедленно. Не «когда-нибудь», не «при следующей сборке» — прямо в этой точке, синхронно.

Следствие 1 · разрушение детерминировано

Фрагмент A. Пока функция работает, на объект смотрит локальная переменная r — точнее, слот фрейма. Когда work возвращается, фрейм уничтожается, все его слоты освобождают свои ссылки, счётчик Res("A") падает до нуля, и деструктор вызывается внутри возврата из функции — до того, как выполнится следующая строка вызывающего кода.

Отсюда сразу два вывода. Первый: with в Python работает не потому, что кто-то аккуратно расставил __exit__, а потому что момент смерти объекта известен точно. Второй, важнее: это свойство реализации, а не языка 🔧. Спецификация обещает, что объект будет уничтожен, и не обещает когда. Фрагмент C — ровно про это: код, который «всегда работал», на другой реализации начинает копить открытые дескрипторы. Поэтому with — не вопрос стиля.

Следствие 2 · счётчик считает всех, включая невидимых

Фрагмент B перестаёт быть загадкой, если помнить, что аргумент функции — тоже ссылка. Имя x даёт единицу, параметр getrefcount внутри вызова — вторую. Отсюда двойка. Во втором случае добавляется ещё и параметр o функции f, отсюда тройка.

Это не курьёз замера, а точное описание работы механизма: каждая передача объекта куда угодно — это инкремент, каждый выход из области — декремент. Счётчик двигается постоянно и на каждый чих.

Следствие 3 · отсюда берётся GIL

Здесь стоит остановиться и вывести самому. Счётчик — обычное целое в памяти. Два потока одновременно освобождают ссылки на один объект. Оба читают значение 2, оба вычитают единицу, оба записывают 1. Одно обновление потеряно, счётчик никогда не дойдёт до нуля — утечка. Хуже симметричный случай: счётчик уходит в ноль раньше времени, объект разрушается под ногами у живой ссылки, и дальше — обращение к освобождённой памяти.

Перечисли варианты, прежде чем читать дальше.

CPython выбрал третье. GIL — не отдельное решение и не недоработка. Это цена детерминированного разрушения. И теперь понятно, почему убрать его так тяжело: счётчик ссылок торчит в публичном C-API, и на предположение «меня никто не трогает параллельно» опираются тысячи расширений.

Следствие 4 · чего механизм не умеет

Фрагмент D. a.peer = b и b.peer = a — это две ссылки, живущие внутри самих объектов. После del a, b внешних ссылок не осталось, но счётчик каждого равен единице: их держит сосед. Ноль недостижим, память не освобождается никогда.

Счётчик по своей природе локален — он знает только «сколько на меня смотрят» и не знает «достижим ли я откуда-то снаружи». Значит нужен второй механизм, работающий с достижимостью, а не с количеством. Ему посвящён следующий урок.

3Границы модели

Где сказанное перестаёт держать

Всё это — про CPython 🔧. PyPy, GraalPy и Jython используют трассирующую сборку и счётчика не имеют вовсе. Код, полагающийся на момент разрушения, там ведёт себя иначе — и это не считается их багом.

Часть объектов бессмертна. Начиная с 3.12 None, True, False, мелкие целые и интернированные строки имеют фиксированный счётчик, который не двигается вообще 🔧. Сделано это ради free-threading: у таких объектов больше нет горячей точки конкуренции. На 3.11 и раньше их счётчики обычные, поэтому sys.getrefcount(None) на разных версиях покажет принципиально разное.

getrefcount нельзя использовать для логики. Он всегда завышен минимум на единицу и зависит от того, сколько временных ссылок держит интерпретатор в этот момент. Как инструмент отладки — годится; как условие в коде — никогда.

4Корень

Решение: временем жизни объекта управляет счётчик ссылок. Выбрано оно в самом начале, в 1990 году, и по причинам, которые тогда выглядели бесспорно.

Счётчик прост: сотня строк, никаких обходов графа, никаких пауз. Он предсказуем: программа не замирает в случайный момент. И, что оказалось важнее всего, он прозрачен для C — расширение может владеть объектом, ничего не зная о сборщике: увеличило счётчик, попользовалось, уменьшило. Именно это свойство сделало возможным весь научный стек — и оно же двадцать лет спустя стало главной причиной, почему GIL нельзя просто удалить.

Цена выяснялась постепенно. Сначала обнаружились циклы — понадобился второй механизм. Потом многопоточность — понадобился глобальный лок. Ни то, ни другое не было очевидно в 1990-м, когда многоядерных машин у обычного разработчика не существовало.

5Аналогия

Это std::shared_ptr, и аналогия здесь не приблизительная, а точная: счётчик в объекте, инкремент при копировании владельца, разрушение при обнулении. Совпадает даже болезнь — циклические ссылки в C++ точно так же не собираются, и лечатся точно так же, weak_ptr против weakref.

Если ты работал с PyTorch, у тебя есть и второе знакомое проявление. Каждый тензор с requires_grad держит ссылки на узлы графа вычислений, а те — на входные тензоры. Пока жива ссылка на loss, жив весь граф до самого входа. Это не особенность фреймворка — это ровно следствие 1 в действии, и оно объясняет самую частую причину OOM в цикле обучения.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему счётчик ссылок, а не только трассирующий сборщик, как в Java?

Потому что в 1990 году счётчик был единственным способом получить детерминированное разрушение бесплатно, а Java решала другую задачу.

Счётчик даёт то, чего трассирующий сборщик не даёт принципиально: объект умирает в тот момент, когда на него перестали смотреть. Отсюда работающий with, отсюда привычка писать open(path).read(), отсюда предсказуемая память в долгоживущем процессе — она не растёт пилой между сборками.

Цена оказалась огромной, и её платят до сих пор:

  • Счётчик неатомарен → нужен глобальный лок → GIL.
  • Счётчик не видит циклы → нужен второй механизм, сборщик поколений.
  • Инкремент и декремент на каждой операции → постоянная запись в память и промахи кеша.
  • Счётчик торчит в публичном C API → его нельзя убрать, не сломав все расширения.

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, JS против Swift ARCТрассирующая сборка отдаёт детерминизм ради параллелизма; ARC перекладывает циклы на программиста
Контрфактуал · те же вопросы у других

Java, Go и JavaScript выбрали трассирующую сборку и не платят за детерминизм. У них нет глобального лока, потоки работают параллельно по-настоящему. Но момент разрушения неизвестен, и поэтому в каждом из этих языков пришлось изобрести отдельную конструкцию для освобождения ресурсов: try-with-resources в Java, defer в Go, using в C#. Python получил ту же гарантию бесплатно, как побочный эффект счётчика.

Swift — самый интересный случай: он выбрал ровно то же решение, только вставляет операции со счётчиком на этапе компиляции. И получил ровно ту же проблему циклов. Только решил её иначе: второго механизма нет, циклы — ответственность программиста, который обязан расставить weak и unowned руками. Python предпочёл доплатить сборщиком и не перекладывать это на человека.

Три языка, одна развилка, три разные цены. Ни один вариант не бесплатен.

🐛 180×Накопление ссылок в цикле обученияlosses.append(loss) держит весь граф: 20 МБ против 0.11 МБ на той же работе
🐛 Баг, который проходит ревью

Цикл обучения копит метрики. Строка выглядит безобидно и убивает процесс по памяти на середине эпохи.

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()). Общее правило выводится прямо из механизма: если в долгоживущий контейнер кладётся объект, вместе с ним удерживается весь достижимый из него граф. Клади наружу минимум.

⚡ приёмНайти держателя за минутуgetrefcount и gc.get_referrers показывают, кто именно не отпускает объект
⚡ Приём, который экономит дни отладки

«Память растёт, кто держит объект — непонятно». На это уходят дни, хотя механизм отвечает за минуту.

Счётчик знает, сколько ссылок. Сборщик знает, кто их держит, и умеет это показать:

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-расширения он не покажет — и это само по себе диагноз, сужающий поиск.

💻 терминалПроверь в REPLЧетыре фрагмента плюс getrefcount(None), деструктор при исключении и самоссылка

Проверь

Прогони четыре фрагмента, сверяясь с предсказанием. Затем — вопросы, ответы на которые выводятся из механизма:

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 при исключениях, и почему именно.

исходники_weakrefset и ABCMetaРегистр подклассов, который обязан не мешать их выгрузке

Где это живёт в реальном коде

Lib/_weakrefset.py — сто строк, реализующие множество, которое не удерживает свои элементы. Само по себе это простая обёртка, но интересно, зачем она в стандартной библиотеке.

Её главный потребитель — ABCMeta. Когда класс регистрируется как реализация абстрактного базового класса, регистр обязан его запомнить — и обязан не мешать ему быть выгруженным. Обычное множество превратило бы каждую регистрацию в вечную ссылку, и ни один такой класс никогда не был бы собран. Загляни в Lib/abc.py и найди _abc_registry: механизм этого урока там виден как проектное ограничение, а не как теория.

Заметь ещё одну деталь: у WeakSet есть колбэк, который вызывается при смерти элемента и вычищает его из множества. Слабая ссылка не просто «не держит» — она умеет сообщить о смерти. Это и делает возможными самоочищающиеся кэши.

L2Где лежит счётчик и сколько стоит его двигатьЧтение счётчика через ctypes, цена INCREF/DECREF, устройство free-threading

Из 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 делает счётчик самой горячей точкой интерпретатора.

L3Где это в исходниках CPythonobject.h, _Py_Dealloc, ceval_gil.c, PEP 683

Тег 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 читается совсем иначе.
  • PEP 683 — бессмертные объекты; мотивация написана ровно в терминах этого урока.
связиКуда это ведётL01, L10, L19 · контраст с трассирующей сборкой и ARC

Связи

корень R3 · Подсчёт ссылок как модель памяти
← основа L01 · Имя, объект, ссылка — счётчик считает ровно те привязки, о которых там шла речь
→ дальше L10 · Циклы, сборщик и память — фрагмент D объясняется там
→ дальше L19 · GIL — следствие 3 разворачивается в полный механизм с интервалами переключения и атомарностью
↔ контраст Трассирующая сборка в Java, Go и JS: нет глобального лока, нет детерминизма
↔ контраст ARC в Swift — тот же счётчик, но циклы переложены на программиста
чего здесь нет Аллокатор и то, почему память не возвращается ОС — в L10
источникиЧто почитатьReference Counting 🟧 · PEP 683 🟥 · weakref 🟦

Что почитать