$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогКак устроен сборщик мусора в CPython: трассировка объектов и циклические ссылки

Как устроен сборщик мусора в CPython: трассировка объектов и циклические ссылки

$ sudo teach IT
·17 июля 2026 г.·9 мин·79
Как устроен сборщик мусора в CPython: трассировка объектов и циклические ссылки

Погружаемся в механизм reference counting и cyclic garbage collector — смотрим на исходный код CPython, пишем тесты и выясняем, когда объект действительно удаляется из памяти.

Содержание
Reference Counting: основа основКак это работаетПроверяем счётчик ссылок из PythonКогда счётчик не помогаетCyclic Garbage Collector: охота на циклыАрхитектура: три поколенияАлгоритм: как находят мусорСмотрим на работу GC в действииОтслеживание объектов через gc.getreferrersПодводные камни и типичные ошибкиdel и циклические ссылкиweakref как инструмент разрыва цикловGC и производительностьПрактика: пишем детектор утечекИтоги

Большинство Python-разработчиков знают, что в языке есть автоматическое управление памятью, и этого им достаточно. Объект создали — Python сам разберётся, когда его удалить. Но стоит начать писать долгоживущие сервисы, работать с большими графами объектов или отлаживать утечки памяти — и это знание перестаёт быть достаточным. Нужно понимать, как механизм работает изнутри.

CPython использует двухуровневую систему управления памятью: основной механизм — подсчёт ссылок (reference counting), вспомогательный — циклический сборщик мусора (cyclic garbage collector). Оба работают одновременно, но решают разные задачи. Разберём каждый из них, заглянем в исходный код и напишем тесты, которые покажут реальное поведение интерпретатора.


Reference Counting: основа основ

Как это работает

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

Вот упрощённое определение PyObject из заголовочного файла Include/object.h:

code
typedef struct _object {
    Py_ssize_t ob_refcnt;       // счётчик ссылок
    PyTypeObject *ob_type;      // указатель на тип объекта
} PyObject;

Макросы Py_INCREF и Py_DECREF управляют счётчиком:

code
#define Py_INCREF(op) (((PyObject *)(op))->ob_refcnt++)

#define Py_DECREF(op)                                   \
    do {                                                \
        PyObject *_py_decref_tmp = (PyObject *)(op);   \
        if (--(_py_decref_tmp->ob_refcnt) == 0) {      \
            _Py_Dealloc(_py_decref_tmp);                \
        }                                               \
    } while (0)

Когда ob_refcnt падает до нуля, вызывается _Py_Dealloc, который запускает деструктор типа (tp_dealloc). Для большинства объектов это означает освобождение памяти прямо здесь и сейчас — без каких-либо пауз и без участия циклического GC.

Проверяем счётчик ссылок из Python

Модуль sys предоставляет функцию getrefcount, которая возвращает текущее значение счётчика. Важный нюанс: при вызове функции создаётся дополнительная ссылка на объект (аргумент функции), поэтому результат всегда на единицу больше «реального» значения.

code
import sys

x = []
print(sys.getrefcount(x))  # 2: одна ссылка x, одна — аргумент функции

y = x
print(sys.getrefcount(x))  # 3: x, y и аргумент

del y
print(sys.getrefcount(x))  # снова 2

Посмотрим, когда именно вызывается __del__:

code
import sys

class Tracked:
    def __init__(self, name):
        self.name = name
        print(f"[{self.name}] создан, refcount={sys.getrefcount(self)}")

    def __del__(self):
        print(f"[{self.name}] уничтожен")

obj = Tracked("A")
print(f"refcount после создания: {sys.getrefcount(obj)}")

ref1 = obj
ref2 = obj
print(f"refcount с двумя алиасами: {sys.getrefcount(obj)}")

del ref1
del ref2
print("ref1 и ref2 удалены, obj ещё жив")

del obj
print("после del obj")

Вывод будет предсказуемым:

code
[A] создан, refcount=2
refcount после создания: 2
refcount с двумя алиасами: 4
ref1 и ref2 удалены, obj ещё жив
[A] уничтожен
после del obj

Объект уничтожается ровно в момент del obj — не позже, не раньше. Это детерминированное поведение, которое отличает CPython от других реализаций Python (PyPy, Jython), где reference counting не используется.

Когда счётчик не помогает

Reference counting элегантен, но у него есть фундаментальный изъян: он не умеет обнаруживать циклические ссылки. Если объект A ссылается на объект B, а B ссылается обратно на A, счётчик ни одного из них никогда не достигнет нуля — даже если из остального кода на эти объекты нет ни одной ссылки.

code
import gc

gc.disable()  # отключим циклический GC, чтобы увидеть проблему чисто

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

    def __del__(self):
        print(f"Node({self.name}) уничтожен")

a = Node("A")
b = Node("B")

a.ref = b  # A → B
b.ref = a  # B → A (цикл!)

del a
del b

print("После del a и del b — деструкторы не вызваны")
# Объекты живы, хотя достичь их из кода уже невозможно

После del a и del b счётчики обоих объектов равны 1 (каждый держит ссылку на другого), но из пользовательского кода они недостижимы. Это классическая утечка памяти в CPython без циклического GC.


Cyclic Garbage Collector: охота на циклы

Архитектура: три поколения

Циклический GC в CPython реализован в файле Modules/gcmodule.c. Он использует алгоритм на основе трассировки достижимости, но применяет его не ко всем объектам подряд, а только к тем, которые могут участвовать в циклах — то есть к контейнерным объектам (списки, словари, экземпляры классов и т.д.).

GC делит объекты на три поколения (generation 0, 1, 2):

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

Пороги срабатывания можно посмотреть и изменить:

code
import gc

print(gc.get_threshold())  # (700, 10, 10) по умолчанию
# Поколение 0 собирается, когда число новых объектов превышает 700
# Поколение 1 — каждые 10 сборок поколения 0
# Поколение 2 — каждые 10 сборок поколения 1

Алгоритм: как находят мусор

Алгоритм циклического GC основан на идее «временного уменьшения счётчиков». Вот его суть:

  1. GC берёт все объекты из текущего поколения.
  2. Для каждого объекта он проходит по его внутренним ссылкам и уменьшает ob_refcnt у каждого объекта, на который есть ссылка (это делается через tp_traverse).
  3. После этого объекты с ob_refcnt > 0 — это те, на которые есть ссылки извне поколения. Они помечаются как достижимые.
  4. Все объекты, достижимые из «живых», тоже помечаются как живые (транзитивно).
  5. Оставшиеся объекты с ob_refcnt == 0 — мусор. Их можно удалить.

Именно поэтому GC требует, чтобы типы, которые могут содержать ссылки на другие объекты, реализовывали tp_traverse и tp_clear. Без этого GC не сможет обойти граф ссылок.

Смотрим на работу GC в действии

code
import gc
import sys

gc.disable()  # управляем GC вручную

class Tracked:
    def __init__(self, name):
        self.name = name
        self.other = None

    def __del__(self):
        print(f"  → {self.name} уничтожен")

# Создаём цикл
a = Tracked("A")
b = Tracked("B")
a.other = b
b.other = a

# Убираем внешние ссылки
del a
del b

print(f"Объектов в поколении 0: {len(gc.get_objects(generation=0))}")
print("Запускаем GC вручную...")

collected = gc.collect(generation=0)
print(f"Собрано объектов: {collected}")

Вывод:

code
Объектов в поколении 0: <N>
Запускаем GC вручную...
  → A уничтожен
  → B уничтожен
Собрано объектов: 4

Число 4 — это два объекта плюс два словаря __dict__ (у каждого экземпляра есть свой __dict__, который тоже участвует в цикле через атрибут other).

Отслеживание объектов через gc.get_referrers

Модуль gc позволяет исследовать граф ссылок напрямую:

code
import gc

class Container:
    pass

obj = Container()
lst = [obj]
dct = {"key": obj}

referrers = gc.get_referrers(obj)
for r in referrers:
    print(type(r), repr(r)[:80])

Это полезно при отладке утечек: можно найти, кто именно держит ссылку на объект, который должен был быть удалён.


Подводные камни и типичные ошибки

del и циклические ссылки

До Python 3.4 объекты с __del__, участвующие в циклах, не собирались GC вообще — они попадали в gc.garbage (список недостижимых, но неудалённых объектов). Начиная с Python 3.4, благодаря PEP 442, это ограничение снято: GC научился безопасно вызывать __del__ для объектов в циклах.

Тем не менее, __del__ остаётся источником проблем:

code
import gc

class Problematic:
    instances = []  # ссылка на уровне класса

    def __init__(self, name):
        self.name = name
        Problematic.instances.append(self)  # объект держит ссылку на себя через класс!

    def __del__(self):
        print(f"{self.name} удалён")

p = Problematic("test")
del p
# Объект НЕ будет удалён, пока Problematic.instances содержит на него ссылку
print(f"Живых экземпляров: {len(Problematic.instances)}")

Это не баг — это ожидаемое поведение. Но именно такие паттерны приводят к утечкам памяти в продакшн-коде.

weakref как инструмент разрыва циклов

Стандартный способ разорвать нежелательный цикл — использовать слабые ссылки (weakref). Слабая ссылка не увеличивает счётчик ссылок:

code
import weakref
import gc

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

    def __del__(self):
        print(f"Node({self.name}) удалён")

parent = Node("parent")
child = Node("child")

# Используем weakref для обратной ссылки
child.parent = weakref.ref(parent)

del parent
# parent удаляется немедленно, потому что weakref не держит счётчик
print("parent удалён")

# Попытка обратиться через weakref после удаления вернёт None
print(child.parent())  # None

Этот паттерн особенно важен при реализации двусвязных структур данных, кэшей и обратных ссылок в ORM-подобных системах.

GC и производительность

Циклический GC — не бесплатная операция. При каждом запуске он приостанавливает выполнение программы (stop-the-world). Для большинства приложений это незаметно, но в высоконагруженных сервисах с большим числом живых объектов это может стать проблемой.

Практика Instagram Engineering (они публично описывали это в своих технических блогах) — отключать GC на воркерах после форка, когда известно, что циклов нет. Это даёт заметный прирост производительности за счёт снижения накладных расходов.

code
import gc

# Отключить автоматический GC (только если вы уверены в отсутствии циклов)
gc.disable()

# Или настроить пороги более агрессивно для долгоживущих объектов
gc.set_threshold(1000, 15, 15)

Для тех, кто хочет разобраться в этих тонкостях системно — как в архитектуре CPython, так и в написании производительного Python-кода — есть смысл посмотреть на программу курса «Senior Python»: там эти темы рассматриваются в контексте реальных задач оптимизации и архитектурных решений.


Практика: пишем детектор утечек

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

code
import gc
import tracemalloc
from collections import Counter

class LeakDetector:
    def __enter__(self):
        gc.collect()
        self.snapshot_before = self._get_object_counts()
        return self

    def __exit__(self, *args):
        gc.collect()
        snapshot_after = self._get_object_counts()
        self.diff = {
            k: snapshot_after.get(k, 0) - self.snapshot_before.get(k, 0)
            for k in set(snapshot_after) | set(self.snapshot_before)
            if snapshot_after.get(k, 0) != self.snapshot_before.get(k, 0)
        }

    def _get_object_counts(self):
        return Counter(type(obj).__name__ for obj in gc.get_objects())

    def report(self):
        leaks = {k: v for k, v in self.diff.items() if v > 0}
        if not leaks:
            print("Утечек не обнаружено")
        else:
            print("Возможные утечки:")
            for typename, count in sorted(leaks.items(), key=lambda x: -x[1]):
                print(f"  {typename}: +{count}")


# Пример использования
class MyObject:
    pass

with LeakDetector() as detector:
    leaked = []
    for _ in range(100):
        obj = MyObject()
        leaked.append(obj)  # намеренно держим ссылки
    # leaked выходит из области видимости... но нет, он создан снаружи блока with

detector.report()

Это грубый инструмент, но он наглядно показывает принцип: сравниваем снимки живых объектов до и после. Для продакшн-отладки существуют более мощные решения — tracemalloc, memory_profiler, objgraph.


Итоги

CPython управляет памятью через два взаимодополняющих механизма. Reference counting — быстрый, детерминированный, работает для большинства случаев. Циклический GC — медленнее, запускается периодически, но умеет то, чего не умеет reference counting: находить и удалять объекты, связанные циклами.

Ключевые выводы для практики:

  • Объект удаляется немедленно, когда его счётчик ссылок падает до нуля. Это предсказуемо и детерминировано.
  • Циклические ссылки не удаляются reference counting — за них отвечает GC.
  • __del__ с Python 3.4 работает корректно даже в циклах, но всё равно остаётся источником неочевидных проблем.
  • weakref — правильный инструмент для обратных ссылок в двусвязных структурах.
  • GC можно и нужно настраивать в высоконагруженных приложениях.

Понимание этих механизмов — не академическое упражнение. Это то, что отличает разработчика, который «пишет код», от того, кто понимает, как этот код живёт в памяти. Если хочется систематически закрыть этот пробел, курс «Senior Python» — один из способов сделать это структурированно, с погружением в CPython и смежные темы.

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

Senior Python

Курс по теме

Senior Python

Senior Python: классы под микроскопом — погружение во внутреннее устройство CPython для middle/senior: от декораторов и метаклассов до байткода и GC. Разбор исходников языка, трассировки и тесты к каждому уроку.

Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Приложения для iPhone и Apple Watch на SwiftUI

Приложения для iPhone и Apple Watch на SwiftUI

Разработка приложений для iPhone и Apple Watch на SwiftUI: навигация, SwiftData, виджеты, часы, выпуск. Нужен Mac с Xcode 27, сами устройства не нужны.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Приложения для macOS на SwiftUI

Приложения для macOS на SwiftUI

Разработка приложений для Mac на SwiftUI: окна и меню, Liquid Glass, SwiftData, сеть, выпуск. Нужен Mac с macOS 27 и Xcode 27.

Перейти к курсу

Другие статьи

Как устроен GIL в Python: что он ограничивает и как это влияет на реальный код
устроен

Как устроен GIL в Python: что он ограничивает и как это влияет на реальный код

Разберём, почему GIL мешает CPU-bound потокам, а для IO-bound иногда всё “выглядит как будто работает”. Поймём, когда выбирать потоки, процессы и asyncio, и как измерить эффект на вашей задаче.

16 сентября 2026 г.
1020
GIL в CPython: почему многопоточность не всегда ускоряет ваш Python-код
cpython

GIL в CPython: почему многопоточность не всегда ускоряет ваш Python-код

Разбираем, как устроен Global Interpreter Lock, почему потоки в Python не работают параллельно на CPU-задачах и когда стоит переключиться на multiprocessing или asyncio.

16 июля 2026 г.
710
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1421
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1070
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

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

2 октября 2026 г.
630

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Reference Counting: основа основКак это работаетПроверяем счётчик ссылок из PythonКогда счётчик не помогаетCyclic Garbage Collector: охота на циклыАрхитектура: три поколенияАлгоритм: как находят мусорСмотрим на работу GC в действииОтслеживание объектов через gc.getreferrersПодводные камни и типичные ошибкиdel и циклические ссылкиweakref как инструмент разрыва цикловGC и производительностьПрактика: пишем детектор утечекИтоги