Skip to content

Latest commit

 

History

History
212 lines (162 loc) · 10.7 KB

File metadata and controls

212 lines (162 loc) · 10.7 KB

Управление памятью: refcounting, GC, weakref, copy/deepcopy

← ООП вглубь · 🏠 Домой · Итератор →


Как CPython понимает, что объект больше не нужен?

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

import sys

a = []
sys.getrefcount(a)   # 2
b = a
sys.getrefcount(a)   # 3
del b
sys.getrefcount(a)   # 2

Подвох. Почему 2, а не 1? Потому что аргумент, переданный в getrefcount, — это ещё одна временная ссылка. Значение всегда на единицу больше «настоящего», и абсолютные числа тут смысла не имеют — важна динамика.

Глубже. Детерминированность — сильная сторона схемы: файл, у которого пропала последняя ссылка, закрывается сразу, поэтому в CPython работает open(...).read() без явного закрытия. Но это деталь реализации: в PyPy и Jython сборка трассирующая, и там тот же код держит дескриптор открытым до следующей сборки. Поэтому ресурсы всё равно закрывают явно — через контекстный менеджер.

С Python 3.12 (PEP 683) часть объектов «бессмертны»: None, True, False, маленькие числа получили фиксированный счётчик, который не меняется, — это убирает запись в общую память при каждом обращении и было необходимо для free-threading.


Зачем тогда нужен сборщик мусора?

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

flowchart LR
    subgraph refcount["Подсчёт ссылок справляется"]
        direction LR
        name1["имя a"] --> obj1["список<br/>refcount = 1"]
        name1 -.->|"del a → refcount = 0"| free1["память освобождена немедленно"]
    end
Loading
flowchart LR
    subgraph cycle["Цикл: refcount бессилен"]
        direction LR
        x["x"] -.->|"del x"| gone1["ссылки снаружи нет"]
        y["y"] -.->|"del y"| gone2["ссылки снаружи нет"]
        n1["Node 1<br/>refcount = 1"] --> n2["Node 2<br/>refcount = 1"]
        n2 --> n1
        n1 -.->|"находит только gc.collect()"| gcm["поколенческий сборщик"]
    end
Loading

Поколения: новые объекты попадают в поколение 0, пережившие сборку переезжают дальше, и старшие поколения проверяются тем реже, чем они старше.

flowchart LR
    new["Новый объект"] --> g0["Поколение 0<br/>проверяется часто"]
    g0 -->|"пережил сборку"| g1["Поколение 1<br/>реже"]
    g1 -->|"пережил сборку"| g2["Поколение 2<br/>совсем редко"]
    g0 -.->|"мусор"| free["Освобождение"]
    g1 -.-> free
    g2 -.-> free
Loading
import gc

class Node:
    def __init__(self): self.ref = None

x = Node(); y = Node()
x.ref = y; y.ref = x        # цикл
del x, y                    # снаружи ссылок нет, но счётчики == 1

gc.collect()
# 2  — объекты освободил уже сборщик, а не refcounting

Сборщик отслеживает только контейнеры (объекты, которые могут ссылаться на другие), проходит их поколениями 0/1/2: молодое поколение проверяется часто, пережившие сборку объекты переводятся в старшее и проверяются реже. Пороги видно в gc.get_threshold(): первое число — сколько выделений минус освобождений накопится, прежде чем проверят поколение 0, второе и третье — сколько сборок поколения 0 (и 1) пройдёт, прежде чем проверят следующее поколение. Дефолт зависит от версии CPython: до 3.12 — (700, 10, 10), начиная с 3.12 — (2000, 10, 0) (третий порог занулили из-за изменений в поколенческом сборщике).

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

Глубже. gc.disable() иногда включают в короткоживущих CPU-задачах и перед fork (сборщик трогает объекты и «пачкает» страницы, ломая copy-on-write). Полностью выключать его в долгоживущем сервисе опасно — циклы накопятся. Отладка: gc.set_debug(gc.DEBUG_LEAK), gc.garbage, tracemalloc для снимков распределения памяти.


Что такое слабая ссылка и когда она нужна?

Коротко. weakref — ссылка, которая не увеличивает счётчик и не мешает объекту умереть. После смерти объекта вызов слабой ссылки даёт None.

import weakref

class Big: ...

obj = Big()
r = weakref.ref(obj)
r() is obj      # True
del obj
r()             # None

Два типовых применения:

  • кеши, которые не должны удерживать значения: weakref.WeakValueDictionary (запись исчезает, когда объект умирает) и WeakKeyDictionary — готовые словари на слабых ссылках;
  • обратные ссылки в структурах: child.parent делают слабой, чтобы дерево разбиралось подсчётом ссылок, а не ждало сборщика циклов.

Подвох. Слабую ссылку поддерживает не всё: на list, dict, tuple, int, str её не создать (TypeError: cannot create weak reference), а класс со __slots__ теряет эту возможность, если не добавить "__weakref__" в слоты (см. ООП вглубь).


Чем copy.copy отличается от copy.deepcopy?

Коротко. copy() создаёт новый контейнер, но кладёт в него те же самые вложенные объекты (shallow). deepcopy() рекурсивно копирует всё дерево.

import copy

orig = [[1, 2], [3, 4]]
shallow = copy.copy(orig)
deep = copy.deepcopy(orig)

orig[0].append(99)
shallow[0]   # [1, 2, 99] — тот же вложенный список
deep[0]      # [1, 2]     — независимая копия

Подвох. Все привычные способы «скопировать список» — тоже поверхностные:

orig[:][0] is orig[0]         # True
list(orig)[0] is orig[0]      # True
orig.copy()[0] is orig[0]     # True

Ровно из-за этого «копия» конфигурации со вложенным словарём продолжает меняться вместе с оригиналом.

Глубже. deepcopy ведёт memo-словарь уже скопированных объектов, поэтому корректно обрабатывает циклы и общие подобъекты (два поля, указывавшие на один список, в копии тоже будут указывать на один). Поведение настраивается методами __copy__/__deepcopy__, а deepcopy дорог — на больших структурах дешевле бывает пересобрать объект явно.


is или ==?

Коротко. is сравнивает идентичность (тот ли это самый объект), == вызывает __eq__ и сравнивает значения. is уместен только для синглтонов: None, True, False, объекты-часовые.

x = int("1000")
y = int("1000")
x == y     # True
x is y     # False — два разных объекта с одним значением

Подвох. Обратный случай коварнее: CPython переиспользует объекты — кеш целых от -5 до 256, интернирование строковых литералов, общие константы внутри одного блока кода. Поэтому написанные рядом x = 1000 и y = 1000 дадут x is y → True (обе ссылки на одну константу), а те же числа, пришедшие из разных мест программы, — False. Код, случайно построенный на is, «работает» ровно до этого момента. Подробнее — в Типах данных.

Практическое правило: x is None — всегда, x == None — никогда; для всего остального ==.


← ООП вглубь · 🏠 Домой · Итератор →