Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Сборка мусора в Python: как работает GC

Сборка мусора в Python: подсчёт ссылок, циклы и поколения

GC в CPython: подсчёт ссылок, циклический сборщик, три поколения, пороги, gc.freeze перед fork и слабые ссылки.

Прямой ответ

В CPython работают два механизма освобождения памяти, и их часто путают.

1. Подсчёт ссылок (reference counting). У каждого объекта есть счётчик ссылок. Как только он достигает нуля, память освобождается немедленно. Это основной механизм: большинство объектов уничтожается именно так, без участия сборщика мусора.

2. Циклический сборщик (cyclic garbage collector). Подсчёт ссылок не справляется с циклами: два объекта, ссылающиеся друг на друга, имеют ненулевые счётчики, хотя извне недоступны. Сборщик периодически ищет такие группы и освобождает их.

class Node:
    def __init__(self, name):
        self.name = name
        self.peer = None

a, b = Node("a"), Node("b")
a.peer, b.peer = b, a     # цикл
del a, b                  # счётчики не нулевые: объекты живы без сборщика

Циклический сборщик работает по поколениям:

  • Поколение 0 — новые объекты. Проверяется часто.
  • Поколение 1 — пережившие одну сборку. Проверяется реже.
  • Поколение 2 — долгоживущие. Проверяется редко.

Пороги задают, сколько операций «выделение минус освобождение» должно накопиться перед сборкой каждого поколения. Значения по умолчанию — (700, 10, 10): сборка поколения 0 каждые 700 новых объектов, поколения 1 — после 10 сборок нулевого, поколения 2 — после 10 сборок первого.

import gc
gc.get_threshold()        # (700, 10, 10)
gc.get_count()            # текущие счётчики
gc.collect()              # принудительная полная сборка
gc.get_stats()            # статистика по поколениям

Важно понимать: сборщик мусора не отвечает за утечки в привычном смысле. Если объект доступен из живой структуры — кэша, глобального списка, замыкания, — он не мусор, и никакой сборщик его не освободит. Поиск таких утечек — отдельная задача, см. утечки памяти в Python.

Практика: что из этого следует

1. Слабые ссылки разрывают циклы. Если один из объектов должен жить, пока жив другой, но не удерживать его, используют weakref:

import weakref

class Cache:
    def __init__(self):
        self._owner = None          # слабая ссылка не мешает освобождению
    def attach(self, owner):
        self._owner = weakref.ref(owner)

2. Кэш без ограничения размера — это не утечка для сборщика. functools.lru_cache хранит ссылки на результаты и аргументы, поэтому объём растёт, пока не достигнет maxsize. Ограничение размера обязательно — см. мемоизацию.

3. gc.freeze() перед fork экономит память. При форке процесса страницы памяти разделяются до первой записи. Если перед форком запустить сборку и «заморозить» оставшиеся объекты, они перестанут участвовать в обходе и меньше копироваться при последующих записях. Это стандартный приём в prefork-серверах:

import gc, os
gc.collect()      # освободить всё, что можно, до форка
gc.freeze()       # исключить оставшиеся объекты из обхода сборщика
os.fork()

4. Полное отключение сборщика — рискованный приём. gc.disable() убирает паузы на обход, но циклические структуры перестают освобождаться вовсе. Это допустимо только если в приложении заведомо нет циклов, что трудно гарантировать.

5. Паузы сборщика заметны на больших кучах. Обход поколения 2 растёт с числом долгоживущих объектов. Если задержка критична, уменьшают объём долгоживущих структур или настраивают пороги, измеряя эффект.

6. Метод __del__ усложняет освобождение. Объекты с финализаторами требуют дополнительного прохода. Современный Python справляется с ними лучше, чем старые версии, но проектировать класс так, чтобы освобождение ресурсов шло через контекстный менеджер, надёжнее.

Рекомендации:

  1. Не полагайтесь на gc.collect() как на решение. Принудительная сборка освобождает только циклический мусор. Рост памяти обычно связан с доступными структурами, а не с циклами.
  2. Ищите циклы при подозрении на утечку. gc.get_objects() и модуль objgraph показывают, кто удерживает объект.
  3. Измеряйте перед настройкой порогов. Изменение порогов влияет и на паузы, и на потребление памяти; эффект зависит от профиля выделения объектов.
  4. Разделяйте кэш и утечку. Неограниченный кэш выглядит как утечка в графиках памяти, но устраняется ограничением размера, а не настройкой сборщика.
  5. Помните о потоковой обработке. Обработка данных порциями вместо загрузки всего объёма уменьшает число живых объектов и снижает нагрузку на сборщик — см. потоковую обработку.

Практический сценарий: рост памяти в долгоживущем воркере

Контекст: воркер обработки задач потреблял 320 МБ после старта и 2,1 ГБ через сутки. Перезапуск по расписанию скрывал проблему.

  1. Гипотеза 1: циклический мусор. Проверка не подтвердилась: принудительная полная сборка освобождала около 40 МБ, после чего память снова росла.
  2. Гипотеза 2: доступные структуры. Снимок кучи показал 1,4 млн объектов одного класса конфигурации соединения, все они были доступны из словаря в модуле подключения.
  3. Причина. Кэш соединений строился по ключу с идентификатором запроса, поэтому каждое обращение создавало новую запись. Ограничения размера не было, слабые ссылки не использовались.
  4. Действия. (а) Ключ кэша заменили на стабильный набор параметров; (б) добавили ограничение размера и время жизни записи; (в) для обратных ссылок на владельца соединения использовали weakref, чтобы разорвать цикл; (г) добавили метрику на число записей в кэше.
  5. Результат. Потребление стабилизировалось на 480 МБ, суточный рост исчез. Пороги сборщика не менялись: они не были причиной.

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

FAQ

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

Можно ли отключить сборку мусора для ускорения? Технически да, через gc.disable(). Практически это приводит к накоплению циклических структур. Если паузы сборщика мешают, лучше уменьшить объём долгоживущих объектов или настроить пороги, измерив результат.

Почему __del__ вызывается не сразу? Объект с финализатором, попавший в цикл, требует дополнительного прохода сборщика. Надёжнее использовать контекстный менеджер или явный метод закрытия, чем полагаться на финализатор.

Влияет ли сборщик на многопоточные приложения? Сборка останавливает выполнение потоков на время обхода, поэтому паузы видны всем. В асинхронных приложениях пауза блокирует весь цикл событий, что стоит учитывать при больших объёмах долгоживущих объектов.

Источники: Python docs (gc, weakref), CPython devguide (garbage collector internals) — ссылки в шапке.