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

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

Вводные слова и зачем изучать менеджер памяти?

Память — это один из самых критичных ресурсов любой программы. Каждый раз, когда вы пишете x = 42 или name = "hello", Python должен где-то в памяти разместить этот объект. А когда объект больше не нужен — освободить занятое место. И всё это должно происходить максимально быстро и эффективно.

Почему это важно для Python-разработчика?

  • Понимание производительности: почему одна программа ест 100 МБ, а другая — 2 ГБ? Почему создание миллиона мелких объектов быстрее, чем кажется? Ответы кроются в устройстве менеджера памяти.

  • Дебаг утечек: когда программа течёт по памяти, знание внутренностей аллокатора — ваш лучший друг.

  • Собеседования: менеджер памяти CPython — одна из любимых тем интервьюеров для Senior-позиций.

Статья не для новичков — я предполагаю, что читатель уже знаком с Python. Как минимум:

  • Понимает, что всё в Python — объект

  • Имеет базовое представление о стеке и куче

  • Слышал про сборщик мусора

Несмотря на объём статьи, мне приходится во многих случаях делать упрощения, чтобы материал было легче усвоить. В некоторых местах я буду обращать на это ваше внимание, а в некоторых — нет.

Краткий ликбез по основам

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

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

Как программы получают память

Когда вашей программе нужна память, она не работает с физической RAM напрямую. Между программой и железом стоит операционная система, которая управляет распределением памяти.

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

Программа → просит у ОС → ОС выделяет кусок виртуальной памяти → этот кусок сопоставляется с физической RAM

Ключевой момент: запрос памяти у ОС — это дорогая операция. Каждый такой запрос — это системный вызов (syscall), который переключает контекст из пользовательского пространства в пространство ядра и обратно. Это как если бы каждый раз, когда вам нужен лист бумаги, вы звонили в канцелярский магазин и заказывали доставку.

Что такое malloc и free

В языке C (на котором написан CPython) для работы с динамической памятью используются функции malloc и free:

// Выделяем 100 байт памяти
void *ptr = malloc(100);

// Используем...

// Освобождаем
free(ptr);

malloc — это стандартный аллокатор из библиотеки libc. Он уже достаточно умный — не вызывает syscall на каждый запрос, а запрашивает у ОС большие куски памяти и раздаёт их по частям. Но, как мы скоро увидим, для нужд Python даже malloc недостаточно эффективен.

Почему Python особенный

Python создаёт колоссальное количество объектов. Вот что происходит, когда вы пишете простейший код:

x = [1, 2, 3]

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

А теперь представьте типичную web-программу, которая обрабатывает тысячи запросов в секунду, создавая и уничтожая миллионы объектов — строки, словари, списки, числа... Каждый из этих объектов нужно где-то разместить, а потом за собой убрать.

При этом подавляющее большинство объектов Python — маленькие. Число int занимает 28 байт, строка "hello" — 54 байта, пустой список — 56 байт. Это важная характеристика, которая будет ключевой для нашего проектирования.

Постановка задачи

Итак, представим, что мы инженеры из CPython Team, и перед нами стоит задача — создать эффективный менеджер памяти для интерпретатора Python. Наши требования:

  • Скорость: выделение и освобождение памяти должно быть максимально быстрым, ведь Python делает это миллионы раз в секунду

  • Экономность: мы не хотим тратить память впустую — overhead на каждый объект должен быть минимальным

  • Минимум syscall'ов: каждый запрос к ОС — дорогой, мы хотим их минимизировать

  • Минимум фрагментации: память не должна превращаться в «швейцарский сыр» с кучей мелких дырок

Общий принцип проектирования: дорогие операции выполняем редко (обращения к ОС), а дешёвые — часто (выделение блоков из подготовленных пулов). Грубо говоря, мы строим многоуровневую иерархию кешей — от крупных кусков к мелким.

Проектируем собственный менеджер памяти

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

1. Наивный подход — malloc на каждый объект

Самое простое, что можно придумать — вызывать malloc для каждого нового объекта, а free — когда объект больше не нужен.

// Создаём объект int
PyObject *obj = (PyObject *)malloc(sizeof(PyLongObject));

// ... используем ...

// Удаляем
free(obj);

Решение вполне рабочее — объекты будут создаваться и удаляться корректно. Но всё ли нас тут устраивает?

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

Проблема 2: Фрагментация. Представьте: мы создали миллион объектов по 28 байт, потом удалили каждый второй. В памяти образовалось 500 тысяч «дырок» по 28 байт. Теперь, если нам нужно выделить 56 байт, malloc может не найти подходящий непрерывный кусок, хотя свободной памяти у нас полно.

Проблема 3: Overhead. malloc хранит метаданные для каждого выделенного блока — как минимум, его размер. Для крупных блоков по 10 КБ эти 8-16 байт метаданных — ерунда. Но для объекта int размером 28 байт, дополнительные 16 байт — это +57% overhead!

Как же быть? Нам нужен промежуточный слой — свой собственный аллокатор, который будет запрашивать память у malloc большими кусками, а раздавать маленькими порциями. Давайте его построим.

2. Арены — просим память большими кусками

Если вызывать malloc на каждый мелкий объект дорого, давайте вызывать его реже! А именно — будем запрашивать память большими кусками, а потом раздавать её маленькими порциями сами.

Назовём такой большой кусок памяти — Arena (арена). Каждая арена будет размером 256 КБ (256 * 1024 = 262 144 байт). Это достаточно много, чтобы вместить тысячи мелких объектов, и достаточно мало, чтобы не отъедать слишком много памяти сразу.

// Вместо тысяч вызовов malloc...
PyObject *obj1 = malloc(28);
PyObject *obj2 = malloc(28);
PyObject *obj3 = malloc(28);
// ...

// ...делаем один вызов и получаем большой кусок
void *arena = malloc(256 * 1024);  // 256 KB

// А дальше раздаём из него порциями
PyObject *obj1 = arena;             // первые 28 байт
PyObject *obj2 = arena + 28;        // следующие 28 байт
PyObject *obj3 = arena + 56;        // и так далее...

Красота! Вместо тысяч вызовов malloc мы делаем один. Это уже огромная оптимизация. Но...

Проблема: как раздавать память внутри арены?

Наш простейший подход «сдвигай указатель на N байт» работает, только если все объекты одного размера. Но Python создаёт объекты самых разных размеров — int (28 байт), float (24 байта), str (от 50 байт), list (56 байт)...

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

Знакомо? Когда у всех объектов был один общий malloc, возникали проблемы с фрагментацией. Решение — разделить большой кусок на более мелкие, специализированные части.

3. Пулы — делим арену на части

Разобьём каждую арену на пулы (Pool) размером 4 КБ каждый. Почему именно 4 КБ? Потому что это размер страницы памяти в большинстве операционных систем, и выравнивание по страницам даёт нам бонус к производительности.

Каждая арена в 256 КБ вмещает ровно 64 пула (256 * 1024 / 4096 = 64).

А теперь ключевая идея — каждый пул будет обслуживать объекты только одного размера. Один пул — для объектов по 16 байт, другой — для объектов по 32 байта, третий — для объектов по 48 байт, и так далее. Таким образом, внутри каждого пула все блоки одинакового размера, и фрагментация исключена полностью!

Но возникает вопрос — каких именно размеров будут наши пулы? Мы же не можем создать пул для каждого возможного размера объекта — их слишком много.

4. Классы размеров — 8, 16, 24, ..., 512

Давайте введём классы размеров (size classes). Вместо того чтобы обслуживать каждый возможный размер, мы округлим запрошенный размер вверх до ближайшего кратного 8 байтам. Получается вот такой набор:

8, 16, 24, 32, 40, 48, 56, 64, 72, ..., 504, 512

Всего 64 класса размеров (от 8 до 512 с шагом 8). Если Python хочет создать объект int размером 28 байт, мы округлим до 32 и разместим его в пуле с блоками по 32 байта.

Запрошено 28 байт → Класс размера: 32 байта
Запрошено 13 байт → Класс размера: 16 байт
Запрошено 64 байта → Класс размера: 64 байта
Запрошено 1 байт  → Класс размера: 8 байт

Да, мы теряем немного памяти на округлении — для объекта в 28 байт мы выделяем 32, «теряя» 4 байта. Но это копейки по сравнению с тем, что мы получаем взамен — полное отсутствие фрагментации внутри пула и мгновенное выделение памяти.

А что если объект больше 512 байт? Тогда наш менеджер просто передаст запрос стандартному malloc. Такие объекты встречаются гораздо реже, и для них overhead malloc уже не критичен. Это разделение «мелкие объекты — наш аллокатор, крупные — malloc» является ключевым архитектурным решением CPython.

Давайте посмотрим, как устроен пул изнутри. Пул на 4 КБ с блоками по 32 байта вместит 4096 / 32 = 128 блоков (минус немного на заголовок пула). А пул с блоками по 8 байт — целых 4096 / 8 = 512 блоков.

Пул (4 KB) для блоков по 32 байта:
┌──────────┬───────┬───────┬───────┬───────┬───────┬─────┐
│ Заголовок│Блок #0│Блок #1│Блок #2│Блок #3│Блок #4│ ... │
│  (meta)  │ 32 B  │ 32 B  │ 32 B  │ 32 B  │ 32 B  │     │
└──────────┴───────┴───────┴───────┴───────┴───────┴─────┘

5. Freelists — переиспользуем блоки

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

Самое простое и элегантное решение — freelist (список свободных блоков). Идея проста: каждый свободный блок содержит указатель на следующий свободный блок, образуя односвязный список.

Когда нужно выделить память:

  1. Берём первый блок из freelist (по указателю freeblock)

  2. Сдвигаем freeblock на следующий свободный блок

  3. Возвращаем блок вызывающему коду

Когда нужно освободить память:

  1. Освобождённый блок ставим в начало freelist

  2. Он теперь указывает на бывший первый свободный блок

  3. freeblock теперь указывает на только что освобождённый блок

Выделение (O(1)):                  Освобождение (O(1)):
                                   
freeblock → [B3] → [B5] → [B9]    freeblock → [B3] → [B5] → [B9]
                                              ↑
Берём B3:                          Освобождаем B7:
freeblock → [B5] → [B9]           freeblock → [B7] → [B3] → [B5] → [B9]

Обе операции выполняются за O(1) — константное время! Никакого поиска, никаких циклов. Это невероятно быстро.

Обратите внимание на хитрый трюк: указатель на следующий свободный блок хранится внутри самого свободного блока. Раз блок свободен, его содержимое нам не нужно, и мы можем использовать это пространство для служебных целей. Ноль дополнительных затрат памяти на freelist!

6. Три состояния пула

Пул с блоками может находиться в одном из трёх состояний:

  • Full — все блоки заняты, свободных нет

  • Used — часть блоков занята, часть свободна (есть непустой freelist)

  • Empty — все блоки свободны

Когда Процессор (в терминах нашей аналогии) ищет блок для нового объекта, ему нужны только Used пулы — в них есть свободные блоки. Full-пулы бесполезны — там всё занято. Empty-пулы — тоже можно использовать, но лучше их придержать.

Чтобы быстро находить нужный пул, для каждого класса размеров мы ведём список Used-пулов — usedpools. Это массив из 64 элементов (по числу классов размеров), каждый — указатель на двусвязный список Used-пулов данного размера.

usedpools[0]  → Pool(8B) ↔ Pool(8B) ↔ Pool(8B)     // пулы для объектов ≤8 байт
usedpools[1]  → Pool(16B) ↔ Pool(16B)               // пулы для объектов ≤16 байт
usedpools[2]  → Pool(24B)                            // пулы для объектов ≤24 байт
...
usedpools[63] → Pool(512B) ↔ Pool(512B)             // пулы для объектов ≤512 байт

Полный алгоритм выделения памяти теперь выглядит так:

  1. Определяем класс размера: size_class = (requested_size + 7) / 8

  2. Смотрим в usedpools[size_class]

  3. Если есть Used-пул → берём блок из его freelist

  4. Если нет Used-пула → берём Empty-пул, инициализируем его для данного класса, переводим в Used

  5. Если нет Empty-пула → выделяем новую Arena

А при освобождении:

  1. Определяем, какому пулу принадлежит блок (по адресу)

  2. Возвращаем блок в freelist пула

  3. Если пул был Full → переводим в Used (добавляем в usedpools)

  4. Если пул стал Empty → переводим в Empty (убираем из usedpools)

7. Собираем всё вместе — трёхуровневая иерархия

Давайте теперь посмотрим на всю картину целиком. У нас получилась элегантная трёхуровневая иерархия:

┌─────────────────────────────────────────────────────────┐
│                     Arena (256 KB)                      │
│                                                         │
│  ┌─────────┬─────────┬─────────┬─────────┐              │
│  │ Pool    │ Pool    │ Pool    │  ...    │              │
│  │ 4 KB    │ 4 KB    │ 4 KB    │         │              │
│  ├─────────┼─────────┼─────────┼─────────┤              │
│  │ ┌──┬──┐ │ ┌──┬──┐ │ ┌──┬──┐ │         │              │
│  │ │B │B │ │ │B │B │ │ │B │B │ │         │              │
│  │ │..│..│ │ │..│..│ │ │..│..│ │         │              │
│  │ └──┴──┘ │ └──┴──┘ │ └──┴──┘ │         │              │
│  │ 32B ea. │ 64B ea. │ 16B ea. │         │              │
│  └─────────┴─────────┴─────────┴─────────┘              │
│                                                         │
└─────────────────────────────────────────────────────────┘
  • Arena (256 КБ) — большой кусок памяти, полученный одним вызовом malloc. Делится на 64 пула.

  • Pool (4 КБ) — обслуживает объекты одного класса размера. Содержит блоки и freelist.

  • Block (8–512 байт) — минимальная единица выделения. Размер кратен 8 байтам.

8. Возврат памяти операционной системе

Мы отлично справляемся с выделением и переиспользованием памяти. Но есть ещё один вопрос — когда и как мы возвращаем память операционной системе?

Здесь CPython поступает хитро. Он не возвращает память ОС при каждом освобождении блока — это было бы слишком дорого. Вместо этого работает следующая логика:

  • Когда блок освобождается → он возвращается в freelist пула

  • Когда все блоки пула свободны → пул переходит в состояние Empty

  • Когда все пулы арены пустые → арена целиком освобождается через free()

Это важный момент: арена может быть освобождена только целиком. Если хоть один блок в хоть одном пуле арены занят, вся арена остаётся в памяти.

Это может приводить к ситуации, когда ваша программа держит в памяти гораздо больше, чем реально использует. Один «прилипший» объект в арене может удерживать все 256 КБ. Эту особенность полезно знать при отладке потребления памяти.

Для оптимизации возврата арен CPython сортирует арены по заполненности — при выделении памяти предпочтение отдаётся наиболее заполненным аренам. Логика простая: если мы будем заполнять полупустые арены, есть шанс, что менее загруженные арены полностью освободятся и их можно будет вернуть ОС.

А теперь — подсчёт ссылок и сборка мусора

Итак, мы спроектировали эффективный аллокатор, который умеет быстро выделять и переиспользовать память. Но остался один важнейший вопрос — когда именно освобождать память? Как понять, что объект больше не нужен?

9. Reference Counting — считаем ссылки

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

Каждый объект Python хранит в своём заголовке поле ob_refcnt — счётчик ссылок:

import sys

a = [1, 2, 3]
print(sys.getrefcount(a))  # 2 (a + аргумент getrefcount)

b = a
print(sys.getrefcount(a))  # 3 (a + b + аргумент getrefcount)

del b
print(sys.getrefcount(a))  # 2

Когда ob_refcnt падает до 0, объект немедленно удаляется — вызывается его деструктор (__del__), а блок памяти возвращается в freelist пула.

Подсчёт ссылок — потрясающе простой и эффективный механизм:

  • Детерминированный: объект удаляется в тот же момент, когда последняя ссылка пропадает

  • Инкрементальный: нет пауз «stop the world» — работа размазана по времени

  • Предсказуемый: __del__ вызывается сразу, что удобно для управления ресурсами (файлы, соединения)

Но у него есть фатальный недостаток — он не справляется с циклическими ссылками.

10. Проблема циклических ссылок

Рассмотрим простой пример:

a = []
b = []
a.append(b)  # a ссылается на b
b.append(a)  # b ссылается на a

del a  # refcount a: 2 → 1 (b всё ещё ссылается)
del b  # refcount b: 2 → 1 (a всё ещё ссылается)

# Оба объекта недостижимы, но refcount = 1 у каждого!
# Утечка памяти!

Это серьёзная проблема, потому что циклические ссылки в Python встречаются постоянно: объекты хранят ссылку на свой класс, класс — на своих наследников, фреймы функций ссылаются друг на друга через traceback, и так далее.

11. Cycle GC — сборщик циклов

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

Алгоритм работает так (упрощённо):

  1. Находим потенциальных кандидатов — это объекты-контейнеры (списки, словари, экземпляры классов), которые могут содержать ссылки на другие объекты. Простые типы (int, str, float) не могут создавать циклы, поэтому GC их игнорирует.

  2. Удаляем внутренние ссылки — для каждого объекта-кандидата создаём временную копию refcount и уменьшаем её на количество ссылок от других кандидатов. Если после этого временный refcount = 0, значит объект жив только благодаря ссылкам из цикла.

  3. Определяем живые объекты — те, у кого временный refcount > 0, достижимы извне. Всё, на что они ссылаются, тоже живое.

  4. Удаляем мусор — всё остальное — мёртвые циклы, которые можно безопасно удалить.

12. Поколения — оптимизируем сборку

Запускать полный обход всех объектов-контейнеров — дорого. Если у нас миллион объектов, то обход миллиона — это ощутимые паузы. Как оптимизировать?

Здесь работает гипотеза поколений (Generational Hypothesis): большинство объектов умирают молодыми. Создали временную переменную в функции — она живёт только пока функция выполняется. Промежуточный список в list comprehension — живёт миллисекунды.

Используя эту гипотезу, разделим все отслеживаемые объекты на три поколения:

  • Поколение 0 (gen0): все новые объекты попадают сюда. Проверяется чаще всего.

  • Поколение 1 (gen1): объекты, пережившие одну сборку gen0. Проверяется реже.

  • Поколение 2 (gen2): объекты, пережившие сборку gen1. Проверяется совсем редко.

import gc
print(gc.get_threshold())  # (700, 10, 10)

Эти три числа означают:

  • 700: когда количество новых аллокаций минус деаллокаций достигает 700, запускается сборка gen0

  • 10: каждые 10 сборок gen0 запускается сборка gen1

  • 10: каждые 10 сборок gen1 запускается сборка gen2

Логика простая: если объект пережил сборку — значит, скорее всего, он живёт долго, и проверять его так часто не нужно. Мы «повышаем» его в следующее поколение, где проверки реже.

Бонус: Специальные оптимизации

CPython идёт ещё дальше и вводит ряд специальных оптимизаций для самых популярных типов.

Кеш малых целых чисел

Числа от -5 до 256 создаются при запуске интерпретатора и никогда не удаляются. Каждый раз, когда вам нужно число 42 — вы получаете один и тот же объект:

a = 42
b = 42
print(a is b)  # True — это один и тот же объект!

a = 257
b = 257
print(a is b)  # False — разные объекты (вне кеша)

Это экономит огромное количество аллокаций, ведь числа вроде 0, 1, -1, 42 используются в программах постоянно.

Интернирование строк

Короткие строки, состоящие из ASCII-букв, цифр и подчёркиваний, автоматически интернируются — кешируются так, чтобы одинаковые строки были одним объектом:

a = "hello"
b = "hello"
print(a is b)  # True — интернированы

a = "hello world!"
b = "hello world!"
print(a is b)  # False — пробел и '!' мешают интернированию

Free lists для популярных типов

Для самых частых типов (float, list, dict, tuple) CPython ведёт собственные freelists — списки недавно удалённых объектов этих типов. Когда нужен новый float — сначала проверяется freelist, и если там есть готовый «каркас», он переиспользуется без обращения к pymalloc.

Создаём float → проверяем float freelist → пусто → pymalloc (Pool → Block)
Удаляем float → кладём в float freelist (не в Pool!)
Создаём float → проверяем float freelist → есть! → берём оттуда (быстрее!)

Вот мы и построили эффективный менеджер памяти CPython. Давайте посмотрим на итоговую картину:

┌──────────────────────────────────────────────────────────────────┐
│                         Python Code                              │
│                    x = 42, name = "hello"                        │
└──────────────────────────┬───────────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│  Уровень 0: Кеши и специальные случаи                            │
│  • Кеш int (-5..256)                                             │
│  • Интернирование строк                                          │
│  • Type-specific freelists (float, list, dict, tuple)            │
└──────────────────────────┬───────────────────────────────────────┘
                           │  Не нашли в кеше?
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│  Уровень 1: pymalloc (объекты ≤ 512 байт)                        │
│  ┌────────────────────────────────────────────────┐              │
│  │ usedpools[size_class] → Pool → freelist → Block│              │
│  └────────────────────────────────────────────────┘              │
│  • 64 класса размеров (8, 16, 24, ..., 512)                      │
│  • Arena (256KB) → Pool (4KB) → Block (8-512B)                   │
│  • Freelist для O(1) аллокации/деаллокации                       │
└──────────────────────────┬───────────────────────────────────────┘
                           │  Объект > 512 байт?
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│  Уровень 2: malloc / libc                                        │
│  • Стандартный системный аллокатор                               │
│  • Для крупных объектов                                          │
└────────────────���─────────┬───────────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│  Уровень 3: Операционная система                                 │
│  • mmap / brk / VirtualAlloc                                     │
│  • Виртуальная → Физическая память                               │
└──────────────────────────────────────────────────────────────────┘

Параллельно работают:
┌──────────────────────────┐  ┌──────────────────────────────────┐
│  Reference Counting      │  │  Cycle GC                        │
│  • ob_refcnt на объекте  │  │  • 3 поколения (gen0, gen1, gen2)│
│  • Мгновенное удаление   │  │  • Пороги: 700 / 10 / 10         │
│  • Не ловит циклы!       │  │  • Ловит циклические ссылки      │
└──────────────────────────┘  └──────────────────────────────────┘

Практические выводы

Знание устройства менеджера памяти даёт конкретные практические преимущества:

1. Понимание потребления памяти. Каждый объект Python имеет overhead: 16 байт на заголовок (ob_refcnt + ob_type) + данные + выравнивание. Число int(1) занимает 28 байт. Словарь из 10 элементов — сотни байт. Зная это, вы можете оценивать потребление памяти ваших структур данных.

2. Миллион мелких объектов — это нормально. Благодаря pymalloc, создание миллиона мелких объектов работает быстро. Не бойтесь создавать много маленьких объектов — аллокатор оптимизирован именно под это.

3. Осторожно с крупными объектами. Объекты > 512 байт обходят pymalloc и идут через обычный malloc. Если вы создаёте и удаляете миллионы крупных объектов — это будет медленнее.

4. Циклические ссылки — не приговор, но знайте про них. GC справится, но лучше использовать weakref где возможно, и помнить, что __del__ у объектов в циклах может вызвать проблемы.

5. Память не всегда возвращается ОС. Даже если вы удалили миллион объектов, память может остаться за процессом Python из-за «прилипших» объектов в аренах. Это нормальное поведение, а не утечка.


Препарируем менеджер памяти на практике

Теория — это прекрасно, но давайте посмотрим на всё это своими глазами. CPython предоставляет несколько инструментов для наблюдения за работой менеджера памяти. Давайте ими воспользуемся.

sys — заглядываем внутрь объектов

Начнём с самого простого — посмотрим, сколько памяти занимают разные объекты:

import sys

# Размеры базовых типов
print(sys.getsizeof(1))           # 28 байт — int
print(sys.getsizeof(3.14))        # 24 байта — float
print(sys.getsizeof("hello"))     # 54 байта — str (5 символов)
print(sys.getsizeof(""))          # 49 байт — пустая строка (overhead!)
print(sys.getsizeof([]))          # 56 байт — пустой список
print(sys.getsizeof([1, 2, 3]))   # 88 байт — список из 3 элементов
print(sys.getsizeof({}))          # 64 байта — пустой dict
print(sys.getsizeof((1, 2, 3)))   # 64 байта — tuple из 3 элементов

Обратите внимание: пустая строка уже занимает 49 байт! Это тот самый overhead объекта Python — заголовок (ob_refcnt + ob_type), хеш, длина, и прочие служебные поля. Каждый «hello» — это не 5 байт, а 54.

А теперь интересный эксперимент — проверим кеш малых целых:

import sys

# Числа -5..256 — это синглтоны, они всегда один и тот же объект
a = 256
b = 256
print(a is b)  # True
print(sys.getrefcount(a))  # Большое число — много кто ссылается на 256

a = 257
b = 257
print(a is b)  # False — разные объекты, кеш не работает

tracemalloc — отслеживаем аллокации

tracemalloc — мощный встроенный инструмент для отслеживания аллокаций памяти. Он позволяет увидеть, где именно в коде выделяется память и сколько.

Базовое использование — кто ест память?

import tracemalloc

tracemalloc.start()

# Ваш код, потребляющий память
data = [dict(key=i, value="x" * 100) for i in range(10_000)]

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")

print("Топ-10 аллокаций по строкам кода:")
for stat in top_stats[:10]:
    print(stat)

Вывод покажет что-то вроде:

<stdin>:5: size=6400 KiB, count=50000, average=131 B

Это значит: строка 5 (создание списка словарей) выделила 6.4 МБ в 50 000 аллокациях, в среднем по 131 байт на аллокацию.

Отслеживаем утечки — сравнение снапшотов

Самый мощный приём — сделать два снапшота и сравнить их. Всё, что выросло между снапшотами — потенциальная утечка:

import tracemalloc

tracemalloc.start()

# Снапшот "до"
snapshot1 = tracemalloc.take_snapshot()

# Код, который подозреваем в утечке
leaked_data = []
for i in range(1000):
    leaked_data.append("x" * 1000)  # Строки, которые никогда не освобождаются

# Снапшот "после"
snapshot2 = tracemalloc.take_snapshot()

# Сравниваем
top_stats = snapshot2.compare_to(snapshot1, "lineno")

print("Разница между снапшотами (потенциальные утечки):")
for stat in top_stats[:5]:
    print(stat)

Вывод покажет прирост памяти с точным указанием строки кода:

<stdin>:9: size=1024 KiB (+1024 KiB), count=1000 (+1000), average=1049 B

Группировка по файлам — находим «прожорливые» модули

import tracemalloc

tracemalloc.start()

# ... ваш код ...

snapshot = tracemalloc.take_snapshot()

# Группируем по файлам вместо строк
top_stats = snapshot.statistics("filename")

print("Потребление памяти по файлам:")
for stat in top_stats[:10]:
    print(f"  {stat.traceback}: {stat.size / 1024:.1f} KiB")

Трассировка — откуда пришла аллокация?

Если включить трассировку с глубиной стека, можно увидеть полный путь вызовов:

import tracemalloc

# Число — глубина стека вызовов для трассировки
tracemalloc.start(25)

# ... ваш код ...

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("traceback")

# Смотрим самую «прожорливую» аллокацию
stat = top_stats[0]
print(f"Самая крупная аллокация: {stat.size / 1024:.1f} KiB")
print("Стек вызовов:")
for line in stat.traceback.format():
    print(f"  {line}")

Это невероятно полезно для отладки — вы увидите не просто «строка X выделила память», а полную цепочку вызовов, которая к этому привела.

gc — заглядываем в сборщик мусора

Модуль gc позволяет наблюдать за работой циклического сборщика мусора:

import gc

# Текущие пороги поколений
print(gc.get_threshold())  # (700, 10, 10)

# Счётчики: (аллокации с последней сборки gen0,
#             сборок gen0 с последней сборки gen1,
#             сборок gen1 с последней сборки gen2)
print(gc.get_count())  # например: (654, 3, 1)

# Сколько объектов отслеживается GC в каждом поколении
print(f"Gen 0: {len(gc.get_objects(0))} объектов")
print(f"Gen 1: {len(gc.get_objects(1))} объектов")
print(f"Gen 2: {len(gc.get_objects(2))} объектов")

Отлавливаем циклические ссылки

import gc

# Включаем режим отладки — GC будет сообщать о найденных циклах
gc.set_debug(gc.DEBUG_SAVEALL)

# Создаём цикл
a = []
b = []
a.append(b)
b.append(a)
del a, b

# Принудительно запускаем сборку
collected = gc.collect()
print(f"Собрано объектов: {collected}")

# gc.garbage содержит объекты, которые GC не смог удалить
# (например, объекты с __del__ в циклах — в старых версиях Python)
print(f"Неудаляемый мусор: {gc.garbage}")

Мониторим GC-паузы с помощью коллбэков

import gc
import time

def gc_callback(phase, info):
    if phase == "start":
        gc_callback._start = time.monotonic()
    elif phase == "stop":
        elapsed = time.monotonic() - gc_callback._start
        print(f"GC gen{info['generation']}: "
              f"собрано {info['collected']}, "
              f"недостижимых {info['uncollectable']}, "
              f"время {elapsed*1000:.2f}мс")

gc.callbacks.append(gc_callback)

# Теперь каждая сборка мусора будет логироваться
data = []
for i in range(10_000):
    data.append({i: [i]})

Практический пример: ищем утечку в web-приложении

Вот реальный паттерн, который часто вызывает проблемы:

import tracemalloc
import gc

tracemalloc.start(10)

# Симулируем обработку запросов
cache = {}

def handle_request(request_id):
    # Ошибка: кеш растёт бесконечно, ключи никогда не удаляются
    result = {"id": request_id, "data": "x" * 500}
    cache[request_id] = result
    return result

# Снапшот до нагрузки
snapshot1 = tracemalloc.take_snapshot()

# Обрабатываем 10_000 "запросов"
for i in range(10_000):
    handle_request(i)

# Снапшот после
snapshot2 = tracemalloc.take_snapshot()

# Ищем утечку
stats = snapshot2.compare_to(snapshot1, "lineno")
print("Потенциальные утечки:")
for stat in stats[:5]:
    print(f"  {stat}")

# Проверяем состояние GC
print(f"\nОтслеживаемые объекты GC: {len(gc.get_objects())}")
print(f"Размер кеша: {len(cache)}")
print(f"Память кеша: {sum(tracemalloc.get_object_traceback(v) is not None 
                         for v in cache.values())}")

Вывод покажет, что функция handle_request — главный потребитель памяти, и сразу станет понятно, что дело в бесконечно растущем cache.

Free-threaded Python 3.13 — революция в менеджере памяти

В Python 3.13 произошло, пожалуй, самое значительное изменение в архитектуре CPython за последние 20 лет — появился free-threaded режим (он же «no-GIL» или PEP 703). И менеджер памяти пришлось переписать практически с нуля. Давайте разберёмся, почему и что изменилось.

Зачем всё менять?

Вспомним: весь наш прекрасный менеджер памяти (Arena → Pool → Block) работал в предположении, что в каждый момент времени только один тред выполняет Python-код — это гарантировал GIL (Global Interpreter Lock). Именно поэтому usedpools, freelists, и даже ob_refcnt не нуждались в синхронизации между тредами.

Но теперь, когда GIL убирают, несколько тредов могут одновременно создавать и удалять объекты. А это значит, что наши структуры данных — usedpools, freelists, счётчики ссылок — становятся разделяемыми ресурсами, к которым нужен синхронизированный доступ. Просто обвесить всё мьютексами — плохая идея, потому что аллокации происходят миллионы раз в секунду, и каждый захват мьютекса — это ощутимые тормоза.

mimalloc — новый аллокатор

Вместо того чтобы латать старый pymalloc, разработчики CPython перешли на mimalloc — высокопроизводительный аллокатор от Microsoft Research, специально спроектированный для многопоточных приложений.

Ключевая идея mimalloc: у каждого треда есть собственная «куча» (thread-local heap). Когда тред хочет выделить память, он работает со своей кучей без каких-либо блокировок. Блокировки нужны только в редких случаях — например, когда один тред освобождает объект, выделенный другим тредом.

Старый pymalloc (с GIL):           mimalloc в Python 3.13 (без GIL):

  Тред 1 ──┐                       Тред 1 → [Своя куча] → Pool → Block
            ├── GIL → usedpools     Тред 2 → [Своя куча] → Pool → Block
  Тред 2 ──┘                       Тред 3 → [Своя куча] → Pool → Block
                                    
  Один тред работает,               Все треды работают параллельно,
  остальные ждут                    каждый со своими структурами

Это очень похоже на идею, которую мы уже обсуждали: вместо одной общей очереди (usedpools), у каждого «процессора» (треда) своя локальная — и блокировки исчезают.

Biased reference counting — хитрый подсчёт ссылок

С подсчётом ссылок проблема ещё серьёзнее. ob_refcnt изменяется при каждом присваивании, при каждом вызове функции, при каждом обращении к элементу списка. Если сделать этот счётчик атомарным (через atomic operations), производительность упадёт катастрофически — атомарные операции в 10-100 раз дороже обычного инкремента.

Решение в Python 3.13 — biased reference counting (PEP 703). Идея основана на наблюдении: в подавляющем большинстве случаев объект используется тем же тредом, который его создал.

Каждый объект хранит два счётчика:

  • Локальный (biased) — для треда-владельца. Изменяется обычными (быстрыми) операциями, без атомарности.

  • Общий (shared) — для всех остальных тредов. Изменяется атомарными операциями.

struct PyObject {
    // Вместо одного ob_refcnt:
    Py_ssize_t ob_ref_local;   // Быстрый, для треда-владельца
    _Py_atomic_ssize_t ob_ref_shared;  // Атомарный, для остальных
    PyTypeObject *ob_type;
};

Когда тред-владелец работает со своим объектом (а это ~95% случаев), он модифицирует ob_ref_local — это обычная запись в память, без блокировок, без атомарных операций. Только когда «чужой» тред берёт или отпускает ссылку, он обращается к ob_ref_shared через атомарную операцию.

Объект удаляется, когда ob_ref_local + ob_ref_shared == 0.

Deferred reference counting — отложенный подсчёт

Ещё одна оптимизация — отложенный подсчёт ссылок для «бессмертных» или часто используемых объектов. Некоторые объекты (модули, встроенные функции, None, True, False) ссылаются друг на друга так часто, что постоянное изменение счётчика — бессмысленная трата ресурсов.

Для таких объектов CPython 3.13 использует специальный флаг: счётчик ссылок «заморожен», и изменения в ob_refcnt просто пропускаются. Такие объекты не удаляются подсчётом ссылок — за ними следит только GC.

Что изменилось в GC

Циклический сборщик мусора тоже пришлось адаптировать:

  • Stop-the-world стал обязательным: при сборке мусора все треды должны быть приостановлены. В обычном CPython (с GIL) это было бесплатно — GIL и так блокировал все треды. В free-threaded режиме нужна явная синхронизация.

  • Поколения переработаны: вместо трёх отдельных списков поколений теперь используется схема с «младшим» и «старшим» пространствами. Это уменьшает количество полных сборок и упрощает многопоточную работу.

Как попробовать

В Python 3.13 free-threaded режим — это экспериментальная фича. Она доступна через специальную сборку:

# Установка через pyenv
pyenv install 3.13.0t    # 't' в конце — free-threaded build

# Или при сборке из исходников
./configure --disable-gil
make

Проверить, работаете ли вы в free-threaded режиме:

import sys
print(sys._is_gil_enabled())  # False в free-threaded сборке

Сравнение: до и после

                          CPython ≤ 3.12          CPython 3.13 (free-threaded)
                          ─────────────           ───────────────────────────
Аллокатор                 pymalloc                mimalloc (thread-local heaps)
Reference counting        ob_refcnt (простой)     ob_ref_local + ob_ref_shared
GIL                       Есть                    Нет (опционально)
Freelists                 Глобальные              Per-thread
GC паузы                  «Бесплатные» (GIL)      Stop-the-world (явный)
Многопоточная аллокация   Невозможна (GIL)        Без блокировок (thread-local)

Важно понимать: free-threaded Python 3.13 — это первый шаг. Производительность однопоточного кода пока немного ниже (5-10%) из-за overhead'а на biased refcounting и атомарные операции. Но многопоточный код, который раньше упирался в GIL, теперь может масштабироваться линейно по количеству ядер — и это огромный шаг вперёд.