Deep Engineering

ЗАМЕР

bench/closure-lifetime/retention.py

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

Цитируется в статье
/ru/interview/python/closures-and-scope
Как запустить
## Версии

**Вывод одинаков на 3.11.15, 3.12.3, 3.13.7 и 3.14.7** — различается только
строка `PY`. Различий по версиям нет.

## Прогон

Запись прогона

Замеры: замыкание и время жизни объекта

Один сюжет, которого нет в bench/closures/: замыкание держит объект живым дольше, чем ожидает вызывающий. Проверяется через weakref — жив объект или мёртв, — и через gc. Ни одного числа времени и ни одного числа байт здесь нет: удержание — факт двоичный, и меряется он двоичным ответом.

скрипт что показывает
retention.py ячейка держит только упомянутые имена; одна ячейка на две функции; обработчик в реестре держит владельца; цикл через ячейку требует сборщика; отпускание присваиванием None

Запуск:

for v in 3.11 3.12 3.13 3.14; do
  echo "== $v"; python$v bench/closure-lifetime/retention.py
done

Версии

Вывод одинаков на 3.11.15, 3.12.3, 3.13.7 и 3.14.7 — различается только строка PY. Различий по версиям нет.

Прогон

$ python3.13 retention.py
PY 3.13.7

1) локальная переменная без замыкания после выхода из функции: мёртв

2) после выхода из make_callback:
   упомянутый в теле data : жив
   неупомянутый unused    : мёртв
   __closure__ замыкания  : 1 ячейка(и)
   co_freevars            : ('data',)
   компилятор кладёт в ячейку только те имена, что реально читаются в теле

3) после del cb: мёртв

4) две функции делят одну ячейку:
   объект: жив
   reader.__closure__[0] is writer.__closure__[0]: True
   после del reader (остался writer): жив
   после del writer: мёртв

5) соединение отпущено вызывающим, обработчик в реестре: жив
   что держит: ('self',)
   после очистки реестра: мёртв

6) цикл «узел -> обработчик -> ячейка -> узел», сборщик выключен: жив
   после gc.collect(): мёртв | собрано объектов: True
   счётчик ссылок такой цикл не разрывает — нужен именно сборщик циклов

7) до release: жив | cb2() = отпускаемый
   после release(): мёртв | cb2() = отпущено
   ячейка осталась, но теперь в ней None — объект отпущен без удаления функции

Что здесь читать

Пункт 1 против пункта 2 — вся суть за две строки. Обычная локальная переменная умирает на выходе из функции; та же переменная, упомянутая во вложенной функции, переживает выход ровно настолько, насколько живёт эта вложенная функция. Пункт 3 закрывает круг: удаляем функцию — умирает объект.

Пункт 2, вторая строка, снимает частое преувеличение: замыкание держит не весь кадр, а только те имена, которые компилятор увидел в теле. unused мёртв, co_freevars — ровно ('data',).

Пункт 5 — тот самый практический случай, ради которого пункт вообще нужен. self попадает в co_freevars обработчика, обработчик лежит в глобальном списке, и del conn не делает ничего. Ни ошибки, ни предупреждения: объект просто не умирает, пока живёт подписка.

Пункт 6 — цикл «объект → его обработчик → ячейка → объект». Со счётчиком ссылок он не разбирается; при gc.disable() объект остаётся жив, gc.collect() его забирает. Это ровно та ситуация, в которой отложенная память выглядит утечкой.

Пункт 7 — как отпустить, не удаляя функцию: nonlocal data; data = None. Ячейка остаётся, а объект в ней — нет.

Первоисточники

Механизм — Doc/reference/executionmodel.rst (тег v3.14.5), раздел «Resolution of names»: свободное имя разрешается в момент выполнения через ячейку объемлющей области. Соответствующая проверка запуском уже есть в bench/closures/ и в уроке про замыкания; здесь она не повторяется.

Что именно попадает в ячейку — Doc/reference/datamodel.rst, строки 1528–1534 (тег v3.14.5):

codeobject.co_freevars — A tuple containing the names of free (closure) variables that a nested scope references in an outer scope. See also function.__closure__. Note: references to global and builtin names are not included.

Перевод: «Кортеж, содержащий имена свободных переменных (переменных замыкания), на которые вложенная область видимости ссылается во внешней области. См. также function.__closure__. Замечание: ссылки на глобальные и встроенные имена сюда не входят.» Пункт 2 замера печатает именно этот кортеж и сопоставляет его с тем, какой объект остался жив.

И там же, строки 592–597, про хранилище:

function.__closure__None or a tuple of cells that contain bindings for the names specified in the co_freevars attribute of the function's code object. A cell object has the attribute cell_contents.

Перевод: «None либо кортеж ячеек, содержащих связывания для имён, указанных в атрибуте co_freevars объекта кода функции. У объекта-ячейки есть атрибут cell_contents.» Пункт 7 замера меняет объект именно через это связывание.

weakref как инструмент проверки — Doc/library/weakref.rst, строки 27–31:

A weak reference to an object is not enough to keep the object alive: when the only remaining references to a referent are weak references, garbage collection is free to destroy the referent and reuse its memory for something else.

Перевод: «Слабой ссылки на объект недостаточно, чтобы объект остался жив: когда единственные оставшиеся ссылки на объект — слабые, сборка мусора вправе уничтожить объект и переиспользовать его память под что-то другое.» Поэтому ref() is not None — корректный ответ на вопрос «держит ли его ещё кто-нибудь по-настоящему».

Оговорка про версию

3.14 здесь — это 3.14.7. Вывод на ней совпадает с 3.13.7 построчно. Пункт 6 зависит от поведения сборщика циклов, и от rc до финала оно меняться не должно — но проверено именно на rc.

Скрипт

184 строк
"""Замыкание держит объект живым: где именно рвётся ожидаемое время жизни.

Один сюжет: функция, определённая внутри другой, попадает в `__closure__` со
всеми свободными именами — и через них удерживает объекты, о которых
вызывающий уже забыл. Проверяется через weakref (жив/мёртв), а не через время
и не через размер: числа времени здесь не нужны и не приводятся.

Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import gc
import sys
import weakref

print("PY", sys.version.split()[0])
print()


class Big:
    """Объект, за жизнью которого следим."""

    def __init__(self, tag):
        self.tag = tag
        self.payload = bytearray(1024)

    def __repr__(self):
        return f"Big({self.tag!r})"


def alive(ref):
    return "жив" if ref() is not None else "мёртв"


# --- 1. Контроль: без замыкания объект умирает сразу -----------------------
def no_closure():
    obj = Big("контроль")
    return weakref.ref(obj)


r = no_closure()
gc.collect()
print("1) локальная переменная без замыкания после выхода из функции:", alive(r))
print()


# --- 2. Замыкание удерживает объект, даже если функция его не использует ---
def make_callback():
    data = Big("данные")
    unused = Big("не используется")   # noqa: F841

    def callback():
        return data.tag             # упомянут только data

    return callback, weakref.ref(data), weakref.ref(unused)


cb, r_data, r_unused = make_callback()
gc.collect()
print("2) после выхода из make_callback:")
print("   упомянутый в теле data :", alive(r_data))
print("   неупомянутый unused    :", alive(r_unused))
print("   __closure__ замыкания  :", len(cb.__closure__ or ()), "ячейка(и)")
print("   co_freevars            :", cb.__code__.co_freevars)
print("   компилятор кладёт в ячейку только те имена, что реально читаются в теле")
print()

# --- 3. Пока жива функция — жив объект -------------------------------------
del cb
gc.collect()
print("3) после del cb:", alive(r_data))
print()


# --- 4. Тот же объект удерживается через ЛЮБОЕ имя в области --------------
def make_two():
    payload = Big("общий")

    def reader():
        return payload.tag

    def writer(v):
        nonlocal payload
        payload = v

    return reader, writer, weakref.ref(payload)


reader, writer, r_shared = make_two()
gc.collect()
print("4) две функции делят одну ячейку:")
print("   объект:", alive(r_shared))
print("   reader.__closure__[0] is writer.__closure__[0]:",
      reader.__closure__[0] is writer.__closure__[0])
del reader
gc.collect()
print("   после del reader (остался writer):", alive(r_shared))
del writer
gc.collect()
print("   после del writer:", alive(r_shared))
print()


# --- 5. Практический случай: обработчик, переживший «владельца» ------------
registry = []


class Connection:
    def __init__(self, tag):
        self.tag = tag
        self.buffer = bytearray(4096)

    def subscribe(self):
        # ЛОВУШКА: замыкание над self — соединение не умрёт, пока
        # обработчик лежит в глобальном registry.
        def on_event(event):
            return f"{self.tag}:{event}"

        registry.append(on_event)


conn = Connection("conn-1")
r_conn = weakref.ref(conn)
conn.subscribe()
del conn
gc.collect()
print("5) соединение отпущено вызывающим, обработчик в реестре:", alive(r_conn))
print("   что держит:", registry[0].__code__.co_freevars)
registry.clear()
gc.collect()
print("   после очистки реестра:", alive(r_conn))
print()


# --- 6. Цикл через замыкание: обычный refcount не справляется --------------
class Node:
    def __init__(self, tag):
        self.tag = tag
        self.handler = None


def make_cycle():
    node = Node("узел")

    def handler():
        return node.tag        # замыкание ссылается на node

    node.handler = handler     # node ссылается на замыкание -> цикл
    return weakref.ref(node)


gc.disable()
r_cycle = make_cycle()
print("6) цикл «узел -> обработчик -> ячейка -> узел», сборщик выключен:",
      alive(r_cycle))
gc.enable()
collected = gc.collect()
print("   после gc.collect():", alive(r_cycle), "| собрано объектов:", collected > 0)
print("   счётчик ссылок такой цикл не разрывает — нужен именно сборщик циклов")
print()


# --- 7. Как разорвать удержание намеренно ----------------------------------
def make_droppable():
    data = Big("отпускаемый")

    def callback():
        if data is None:
            return "отпущено"
        return data.tag

    def release():
        nonlocal data
        data = None

    return callback, release, weakref.ref(data)


cb2, release, r_drop = make_droppable()
gc.collect()
print("7) до release:", alive(r_drop), "| cb2() =", cb2())
release()
gc.collect()
print("   после release():", alive(r_drop), "| cb2() =", cb2())
print("   ячейка осталась, но теперь в ней None — объект отпущен без удаления функции")