Deep Engineering
Средний·Опубликовано·3.11 · 3.12 · 3.13 · 3.14·25 МИН

Сборка мусора: почему объект жив, хотя ссылок на него нет

В обычной сборке CPython объект умирает в тот момент, когда на него пропадает последняя ссылка, — сборщик для этого не нужен. Сборщик нужен для циклов, и только для них. А объект, который «не умирает», может держать не мусорный цикл, а ссылка от живого объекта, которой не видно в коде: трассировка исключения, ключ кеша, метод в списке подписчиков.

Полное техническое изложение

TL;DR

В обычной сборке CPython объект умирает, когда пропадает последняя ссылка на него, — сразу, на этой же строке. Этим занимается счётчик ссылок, а не сборщик мусора. del не уничтожает объект, а убирает имя и уменьшает счётчик на единицу. Сборщик (gc) нужен ровно для одного случая, который счётчик не умеет: цикл. Две вершины ссылаются друг на друга, имён больше нет, а счётчики остаются по единице — и объекты живут до gc.collect(), который находит группу без внешних ссылок и освобождает её.

Дальше — то, что отличает знающего от читавшего. Когда память «не освобождается», искать стоит не только мусорный цикл, но и ссылку от живого объекта, которой не видно в коде. Исключение, сохранённое в атрибут, держит трассировку, трассировка — кадр упавшей функции, кадр — все его локальные переменные. lru_cache на методе держит self в ключе. Метод, положенный в список подписчиков, держит свой объект в __self__. Всё это достижимо от живого объекта, и сборщик этого не тронет — gc.collect() в прогоне ничего не менял. Зато одна правка рвёт путь, и объект умирает на той же строке. А за полную сборку платят не мусором, а живыми объектами: миллион живых списков — 39,88 мс на gc.collect() без единого мусорного объекта (3.13.7).

С 3.13 у CPython две сборки, и всё сказанное выше — про обычную, с GIL. В сборке без GIL (3.13t, 3.14t) экземпляр, созданный и отпущенный одним потоком, тоже умирает на строке del. Но функция уровня модуля после del ждёт сборщика, объект, последнюю ссылку на который отпустил чужой поток, эту строку переживает, а gc.freeze() снимает со сборки не всё.

Порог входа
Перед уроком достаточно понимать
  • что переменная — это имя, привязанное к объекту;
  • что объект можно положить в список, в словарь, в атрибут другого объекта;
  • как работают try / except и что такое метод экземпляра.
Заранее знать не нужно
  • счётчик ссылок, gc.collect(), недостижимые циклы,weakref;
  • трассировка и кадр в исключении, lru_cache на методе, WeakMethod, PEP 442, gc.freeze().

База: объект умирает, когда пропадает последняя ссылка

У каждого объекта в CPython есть счётчик: сколько ссылок на него сейчас существует. Имя — ссылка. Элемент списка — ссылка. Атрибут другого объекта — ссылка. Когда ссылка появляется, счётчик растёт, когда пропадает — уменьшается. Дошёл до нуля — объект освобождается сразу.

Так устроена обычная сборка CPython — та, что ставится по умолчанию. С 3.13 есть и вторая, без GIL; у правила там есть исключения, они разобраны в конце урока. Ответу уровня Junior они не мешают: для объекта, который создаёт и отпускает один поток, правило верно в обеих сборках.

Отсюда ответ уровня Junior на вопрос «как в Python освобождается память»: основной механизм — подсчёт ссылок: объект освобождается, как только на него не остаётся ни одной ссылки. Сборщик мусора дополняет его и нужен для циклов.

И сразу следствие, на котором спотыкаются: del x не удаляет объект. Он удаляет имя x и уменьшает счётчик на единицу. Если это была последняя ссылка — объект умрёт. Если нет — будет жить дальше.

Проверить «жив ли объект» можно, не удерживая его, — слабой ссылкой из модуля weakref. Она не увеличивает счётчик, а после смерти объекта возвращает None. Этим приёмом сделаны все прогоны урока.

Механизм 1: цикл — единственное, чего счётчик не умеет

наблюдение замераbench/gc/alive.py, разделы 1–2. Сборщик выключен на время опыта, чтобы не сработать сам между строками; одинаково на 3.11–3.14.

Без цикла всё происходит на строке del:

1) без цикла
   до del: жив
   сразу после del: мёртв — освобождён счётчиком ссылок, сборщик не нужен

Теперь две вершины, которые ссылаются друг на друга:

PYTHON
a, b = Node("a"), Node("b")
a.other, b.other = b, a
del a, b
2) цикл из двух вершин
   sys.getrefcount(a) до del: 3
   после del a, b: жив и жив
   gc.collect() вернул 2; теперь: мёртв и мёртв

sys.getrefcount показывает 3: имя a, ссылку из b.other и собственный аргумент функции — третью единицу добавляет сам вызов. После del имён нет, но у каждой вершины осталось по ссылке — от соседа. Счётчик не опустится до нуля никогда: из программы до вершин не добраться, а друг друга они держат.

Такую группу и ищет сборщик циклов. Документация описывает его роль одной фразой:

the collector supplements the reference counting already used in Python (сборщик дополняет подсчёт ссылок, который в Python уже есть).

Он обходит отслеживаемые объекты (контейнеры — списки, словари, экземпляры классов; числа и строки он не отслеживает, у них не бывает ссылок на другие объекты) и находит группы, на которые нет ссылок извне. gc.collect() возвращает число найденных недостижимых объектов — здесь 2. Запускается он и сам, по порогам числа созданных объектов; как устроены эти пороги и поколения, разобрано в статье об управлении памятью.

Механизм 2: объект жив не из-за цикла, а из-за ссылки, которой не видно

наблюдение замераbench/gc/alive.py, разделы 3–5; одинаково на 3.11–3.14.

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

Трассировка исключения держит кадр

У исключения есть атрибут __traceback__. Трассировка держит кадр каждой функции, через которую прошло исключение, а кадр — все её локальные переменные. Справочник языка называет это прямо, объясняя, почему имя из except ... as e исчезает после блока:

Exceptions are cleared because with the traceback attached to them, they form a reference cycle with the stack frame, keeping all locals in that frame alive until the next garbage collection occurs.

перевод

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

— Справочник языка, оператор try

Прогон проверяет три случая: исключение нигде не сохранено; сохранено в локальную переменную той же функции; сохранено в атрибут живого объекта.

3) исключение и кадр упавшей функции
   исключение нигде не сохранено: payload после выхода из функции — мёртв
   сохранено в локальную err: payload после выхода — жив
     цикл: кадр -> err -> исключение -> __traceback__ -> кадр
     после gc.collect(): мёртв
   сохранено в svc.last_error: payload после выхода — жив
     путь от живого svc: svc -> исключение -> __traceback__ -> кадр -> payload
     gc.collect() при живом svc: жив
     svc.last_error = None: мёртв
   имя e после блока except: NameError — его удалил сам интерпретатор

Второй и третий случаи выглядят одинаково — «payload жив», — а устроены по-разному, и от этого зависит лечение. Локальная err = e возвращает тот самый цикл из справочника: после выхода из функции до него не добраться, и объект ждёт сборщика. С атрибутом svc.last_error всё достижимо от живого сервиса — и сборщик это не тронет: gc.collect() при живом svc объект не освободил. Но и ждать его не нужно: svc.last_error = None, и объект умер на этой же строке. Хранить в атрибуте стоит текст ошибки, а не само исключение.

lru_cache на методе держит self

4) functools.lru_cache на методе
   del rep: жив — self лежит в ключе кеша
   gc.collect(): жив
   Report.total.cache_clear(): мёртв

Кеш живёт на функции, то есть на классе, а self — часть ключа. Документация Python описывает это в FAQ:

The disadvantage is that instances are kept alive until they age out of the cache or until the cache is cleared (Недостаток в том, что экземпляры остаются живыми, пока их не вытеснят из кеша или пока кеш не очистят).

При maxsize=None вытеснения нет вовсе, и каждый экземпляр, у которого хоть раз вызвали метод, живёт до cache_clear() или до конца процесса.

Метод в списке подписчиков держит объект

5) связанный метод в списке подписчиков
   callbacks.append(w.on_event); del w: жив — метод держит __self__
   gc.collect(): жив
   callbacks.clear(): мёртв
   то же через weakref.WeakMethod; del w: мёртв
   weak_callbacks[0]() -> None

w.on_event — связанный метод, объект, у которого в __self__ лежит w. Подписать метод на событие значит держать сильную ссылку на весь объект. weakref.WeakMethod ссылается на него слабо: объект умирает, подписка возвращает None, и её можно выбросить при следующей рассылке.

Механизм 3: __del__ в цикле с 3.4 не мешает сборке

До Python 3.4 цикл, в котором у объекта был __del__, сборщик не трогал: было неясно, в каком порядке вызывать финализаторы, если объекты ссылаются друг на друга. Такие объекты копились в gc.garbage. PEP 442 это снял:

to be able to define and run finalizers for any object, regardless of their position in the object graph (чтобы можно было определять и запускать финализаторы для любого объекта, независимо от его положения в графе объектов).

6) __del__ у обеих вершин цикла
   gc.collect() вернул 2; финализаторы выполнились: ['a', 'b']
   gc.garbage: []

Оба финализатора вызваны, gc.garbage пуст. Но порядок, в котором они вызываются, PEP явно оставляет неопределённым: For CI objects, the order in which finalizers are called (step 2 above) is undefined (Для объектов в циклической изоляции порядок вызова финализаторов (шаг 2 выше) не определён). Здесь вышло a, потом b — полагаться на это нельзя. И в __del__ объекта из цикла сосед может быть уже финализирован.

Глубже: за что платит полная сборка

наблюдение замераbench/gc/pause.py, CPython 3.13.7, лучшее из семи вызовов. Времена сняты на одной машине; содержательно, что время растёт с числом живых объектов, а не мусорных.

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

PY 3.13.7 | лучшее из 7 вызовов gc.collect()

  живых списков   gc.collect()   на объект
         10 000        0.75 мс     45.8 нс
        100 000        3.57 мс     32.8 нс
      1 000 000       39.88 мс     39.6 нс
    пустая куча        0.29 мс

Несколько десятков наносекунд на живой объект — в этой таблице от 32,8 до 45,8 нс: миллион живых списков — почти 40 мс паузы на полную сборку. Сам сборщик запускает полную сборку редко — пороги рассчитаны на то, чтобы большая часть работы шла в младшем поколении, — но процесс с большим долгоживущим кешем в памяти платит за него на каждой такой сборке.

Убрать живое из обхода умеет gc.freeze(), появившийся в 3.7: он переносит всё отслеживаемое в постоянное поколение, и следующие сборки его не обходят. Но придуман он не как общее средство от пауз, а под один случай — о нём ниже.

  тот же миллион живых списков
    до gc.freeze():      38545.7 мкс
    после gc.freeze():       0.3 мкс, в постоянном поколении 1 005 045 объектов

Документация предлагает его для процессов, которые делают fork без exec: сборщик в дочернем процессе не трогает замороженные объекты, и меньше страниц памяти копируется при записи. Как общий приём против пауз он годится с двумя оговорками. Мусорные циклы среди замороженного больше не собираются, пока не вызван gc.unfreeze(), — то есть это память, отданная навсегда. И в сборке без GIL заморозка снимает со сборки не всё; это видно в следующем разделе.

Глубже: сборка без GIL — то же правило, но с исключениями

наблюдение замераbench/gc/freethreaded.py на 3.11–3.14 с GIL и на 3.13.7 и 3.14.7 без GIL; ниже — вывод 3.14t. Сюжеты 1–3 — наблюдения без времени; в сюжете 4 времена с одной машины, содержательно сравнение до и после внутри прогона.

С 3.13 CPython собирается и без GIL (PEP 703). Счётчик ссылок там устроен иначе: у объекта есть поток-владелец, и операции из этого потока идут по быстрому пути, а из чужого — по общему. Как это сказывается на скорости, разобрано в статье о GIL, как на размере объекта — в статье об управлении памятью. Здесь — что это значит для вопроса «когда объект умрёт».

Главное правило урока держится: экземпляр, созданный и отпущенный одним потоком, умирает на строке del и без GIL. Все шесть опытов bench/gc/alive.py на 3.13t и 3.14t дают тот же ответ «жив или мёртв», что с GIL. Но три вещи расходятся:

2) функция, объявленная на уровне модуля
   сразу после del: жив
   после gc.collect(): мёртв
   для сравнения, вложенная функция сразу после del: мёртв
3) последнюю ссылку удаляет не тот поток, что создал объект
   жив в том потоке сразу после del: 19 из 20
   жив в главном потоке после join(): 0 из 20
4) gc.freeze() и миллион живых списков, лучшее из 7 вызовов gc.collect()
   до gc.freeze():      37272.2 мкс
   после gc.freeze():   23155.0 мкс, в постоянном поколении 1 007 024 объектов

Функция уровня модуля ждёт сборщика. С GIL она умирает на строке del, без GIL — только на gc.collect(); вложенная функция умирает сразу в обеих сборках. Тот же эффект виден и в bench/gc/alive.py: в шестом опыте gc.collect() без GIL вернул 3, а не 2, и третьим оказалась функция тела класса — с GIL её освободил счётчик сразу после создания класса.

Последняя ссылка из чужого потока. Объект создал главный поток, а последнюю ссылку забрал и удалил другой. Сразу после del в том потоке объект был жив в 19 попытках из 20 на 3.14t и в 20 из 20 на 3.13t; в главном после join() — мёртв во всех. С GIL — мёртв сразу, 0 из 20 на всех четырёх версиях. Число попыток напечатано не случайно: результат зависит от расписания потоков.

gc.freeze() снимает не всё. С GIL после заморозки сборка миллиона живых списков занимает доли микросекунды — 0,2–0,3 мкс в этом же прогоне. Без GIL — 23,2 мс из 37,3 на 3.14t и 23,4 из 63,5 на 3.13t: заметная часть работы сборщика остаётся.

Отсюда точная формулировка для собеседования: в обычной сборке CPython объект без цикла умирает сразу, когда счётчик доходит до нуля; в сборке без GIL это верно для объекта одного потока, но не для всех объектов.

Как отвечать на собеседовании

Короткий ответ: в CPython память освобождается прежде всего подсчётом ссылок: объект умирает, как только на него пропадает последняя ссылка, сразу. del удаляет имя, а не объект. Сборщик мусора дополняет счётчик и нужен для циклов: объекты, ссылающиеся друг на друга, счётчик не освободит никогда, и gc находит такие группы без внешних ссылок.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

Если спросят про утечки, хороший ответ начинается не с циклов, а с того, что объект может держать обычная ссылка от живого объекта, которой не видно: исключение в атрибуте держит трассировку, кадр и все его локальные переменные; lru_cache на методе держит self в ключе; метод в списке подписчиков держит объект в __self__. Всё это достижимо, и сборщик такое не тронет. Лечится одной правкой: хранить текст ошибки, чистить кеш, подписываться через weakref.WeakMethod.

Стоит добавить, почему имя из except ... as e исчезает после блока: иначе кадр держал бы исключение, исключение через трассировку держало бы кадр, и получился бы цикл со всеми локальными переменными. Это написано в справочнике языка. И про цену сборки: она растёт с числом живых объектов — миллион живых списков, 39,88 мс на gc.collect() без мусора (3.13.7), — а gc.freeze() убирает их из обхода — в обычной сборке.

Если речь зайдёт о сборке без GIL, стоит сказать, что «умирает на той же строке» там верно не всегда: функция уровня модуля ждёт сборщика, а объект, последнюю ссылку на который отпустил чужой поток, переживает del в этом потоке.

Дальше спросят

Спросят дальше

Можно ли выключить сборщик?

Короткий ответ

Можно — gc.disable(), и документация это допускает, если программа не создаёт циклов. Подсчёт ссылок продолжит работать, и объекты без циклов будут освобождаться как раньше. Но любой цикл — включая цикл «кадр — исключение» — будет жить до явного gc.collect() или до конца процесса. Мягче gc.freeze(): он убирает из обхода то, что уже создано, а новые циклы продолжают собираться.

Спросят дальше

Как найти, кто держит объект?

Короткий ответ

gc.get_referrers(obj) возвращает объекты, которые ссылаются на него напрямую, — документация просит использовать его только для отладки. По цепочке таких вызовов доходят до держателя: атрибута, кеша, списка подписчиков. Проверить, умер ли объект после правки, — слабой ссылкой, как в прогонах урока.

Спросят дальше

А __del__ в цикле — это утечка?

Короткий ответ

С 3.4 — нет: PEP 442 разрешил сборщику финализировать такие циклы, и gc.garbage пуст. Но порядок вызова финализаторов в цикле не определён, и в __del__ сосед по циклу может быть уже финализирован.

Частые заблуждения

Утверждение

del x удаляет объект

На самом деле

Он удаляет имя x и уменьшает счётчик ссылок на единицу. Объект умирает, только если это была последняя ссылка. В цикле из двух вершин после del a, b обе живы и умирают только на gc.collect().

Утверждение

Память в Python освобождает сборщик мусора

На самом деле

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

Утверждение

Если объект не освобождается, где-то цикл

На самом деле

Держать может и обычная ссылка от живого объекта, которой не видно в коде: исключение в атрибуте держит кадр и все его локальные переменные, lru_cache на методе держит self, связанный метод в списке подписчиков держит объект. Всё это достижимо: gc.collect() в прогоне такой объект не освободил, а одна правка освобождает его на той же строке.

Утверждение

Объекты с __del__ в цикле никогда не собираются

На самом деле

Так было до 3.4. С PEP 442 такие циклы собираются: в прогоне gc.collect() вернул 2, оба финализатора выполнились, gc.garbage пуст. Не определён только порядок вызова финализаторов.

Утверждение

Объект без ссылок умирает сразу в любой сборке CPython

На самом деле

Так в обычной сборке, с GIL. В сборке без GIL функция уровня модуля после del жива и умирает только на gc.collect(), а объект, последнюю ссылку на который удалил не тот поток, что его создал, в этом потоке сразу после del жив — 19 попыток из 20 на 3.14t. Экземпляр одного потока умирает сразу в обеих сборках.

Утверждение

Сборка мусора стоит тем дороже, чем больше мусора

На самом деле

Полная сборка обходит все отслеживаемые объекты, живые тоже. Без единого мусорного объекта: 0,75 мс при десяти тысячах живых списков и 39,88 мс при миллионе (3.13.7). После gc.freeze() тот же миллион в обход не попадает.

История версий

ВерсияИзменениеЧто это значит для кода
3.4PEP 442: циклы с __del__ собираются, в gc.garbage они больше не попадают. До этого совет был — не допускать объектов с __del__ в циклах.
3.7Появляются gc.freeze(), gc.unfreeze() и gc.get_freeze_count() — способ убрать долгоживущие объекты из обхода сборщика, прежде всего перед fork.
3.13Порог младшего поколения по умолчанию — 2000 вместо 700. Проверено прогоном: gc.get_threshold() даёт (700, 10, 10) на 3.11.15 и 3.12.3 и (2000, 10, 10) на 3.13.7 и 3.14.7. Смена порога видна и в исходнике на теге v3.13.0; что именно считают эти числа — в статье об управлении памятью. И появляется сборка без GIL (PEP 703, в sys.version — experimental free-threading build): экземпляр одного потока умирает на строке del и там, а функция уровня модуля ждёт сборщика (bench/gc/freethreaded.py).
3.14В обычной сборке: в 3.14.0 сборщик стал инкрементальным, в 3.14.5 это откатили — подробности и источник в статье об управлении памятью. На поведение, разобранное в этом уроке, это не влияет: все шесть опытов bench/gc/alive.py дают на 3.14.7 тот же вывод, что на 3.11–3.13. Сборка без GIL (в sys.version — уже free-threading build, без experimental) собрана с другим сборщиком — путь исходника в библиотеке Python/gc_free_threading.c против Python/gc.c — и ведёт себя иначе: gc.freeze() снижает полную сборку миллиона живых списков с 37,3 до 23,2 мс, а не до долей микросекунды.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Две вершины ссылаются друг на друга, сборщик выключен. Что напечатает этот код?
class Node:
  pass


gc.disable()
a = Node()
b = Node()
a.other = b
b.other = a
r = weakref.ref(a)
del a, b
print(r() is None)
gc.collect()
print(r() is None)

Практика · оцените

Мусора нет ни одного. Живых списков было 100 тысяч, стал миллион. Во сколько раз дольше полная сборка gc.collect()?
раза

Проверьте себя

Вопрос 1 из 4

Объект без циклов, на него одна ссылка — имя x. Сборщик выключен через gc.disable(). Что происходит на del x?

Чем измерено

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

Источники и что читать дальше

6 ИСТОЧНИКОВ

  1. Справочник языка — оператор try, ветка exceptОфициальная документация. Самое прямое объяснение того, как трассировка держит память: «Exceptions are cleared because with the traceback attached to them, they form a reference cycle with the stack frame, keeping all locals in that frame alive until the next garbage collection occurs» (Исключения удаляются, потому что вместе с прикреплённой трассировкой они образуют цикл ссылок с кадром стека, и все локальные переменные этого кадра остаются живыми до следующей сборки мусора). Отсюда и то, что имя после except as исчезает.https://docs.python.org/3.14/reference/compound_stmts.html#except-clause
  2. gc — интерфейс сборщика мусораОфициальная документация. Роль сборщика: «the collector supplements the reference counting already used in Python» (сборщик дополняет подсчёт ссылок, который в Python уже есть). gc.collect: «With no arguments, run a full collection» (Без аргументов выполняет полную сборку). gc.freeze, добавлен в 3.7: «Freeze all the objects tracked by the garbage collector; move them to a permanent generation and ignore them in all the future collections» (Заморозить все объекты, отслеживаемые сборщиком: перенести их в постоянное поколение и игнорировать во всех будущих сборках).https://docs.python.org/3.14/library/gc.html
  3. PEP 442 — Safe object finalizationPEP. Антуан Питру, Final, Python 3.4. До него объект с __del__ в цикле сборщик не трогал. Цель PEP: «to be able to define and run finalizers for any object, regardless of their position in the object graph» (чтобы можно было определять и запускать финализаторы для любого объекта, независимо от его положения в графе объектов). И граница гарантии: «For CI objects, the order in which finalizers are called (step 2 above) is undefined» (Для объектов в циклической изоляции порядок вызова финализаторов (шаг 2 выше) не определён).https://peps.python.org/pep-0442/
  4. Programming FAQ — How do I cache method calls?Официальная документация. Почему lru_cache на методе держит экземпляры: «It creates a reference to the instance unless special efforts are made to pass in weak references» (Он создаёт ссылку на экземпляр, если специально не передавать слабые ссылки) и «The disadvantage is that instances are kept alive until they age out of the cache or until the cache is cleared» (Недостаток в том, что экземпляры остаются живыми, пока их не вытеснят из кеша или пока кеш не очистят).https://docs.python.org/3/faq/programming.html#faq-cache-method-calls
  5. PEP 703 — Making the Global Interpreter Lock Optional in CPythonPEP. Сэм Гросс, Final, Python 3.13. Сборка CPython без GIL и её счётчик ссылок: у объекта есть поток-владелец, и операции из этого потока идут по быстрому пути, из чужого — по общему; основание — наблюдение, что «most objects are only accessed by a single thread, even in multi-threaded programs» (к большинству объектов обращается только один поток — даже в многопоточных программах). Что из этого следует для времени жизни объекта, урок проверяет прогоном bench/gc/freethreaded.py на 3.13t и 3.14t, а не пересказом.https://peps.python.org/pep-0703/
  6. weakref — слабые ссылкиОфициальная документация. Определение, на котором держится проверка живости в прогонах урока: «A weak reference to an object is not enough to keep the object alive» (Слабой ссылки на объект недостаточно, чтобы удержать его в живых). И WeakMethod: «Since a bound method is ephemeral, a standard weak reference cannot keep hold of it» (Поскольку связанный метод эфемерен, обычная слабая ссылка не может его удержать).https://docs.python.org/3/library/weakref.html