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

Идея сделать приложение-визуализатор пришла после чтения книги CPython Internals. Там подробно объясняется, как устроены арены, пулы и блоки. Но пока читаешь текст, всё это воспринимается слишком абстрактно. Захотелось увидеть механику своими глазами: как память выделяется, как освобождается и почему иногда остаётся занята. Так и появился MemoryMonitorApp, а вместе с ним — эта статья.

Здесь мы разберём, зачем Python свой аллокатор памяти, как работает pymalloc, что происходит с объектами при удалении и как выглядят циклические ссылки в действии. Всё это можно будет посмотреть через визуализатор: создавать объекты, наблюдать их распределение по пулам, а потом освобождать и смотреть, что останется.

Небольшой дисклеймер: приложение делалось и проверялось только на машине автора. На других системах оно может выглядеть и работать чуть иначе, а местами — неидеально, и в целом, что-то может не работать, в гитхабе пока что первая версия. Но для иллюстрации устройства памяти этого более чем достаточно. Если видите какие-либо неточности, опечатки или хотели бы что-то предложить и т.д., напишите пожалуйста в комментарии, буду благодарен.


Почему Python понадобился свой аллокатор

Когда мы пишем на C, всё честно: вызвал malloc, получил кусок памяти, потом не забудь вызвать free. В Python же программист об этом даже не задумывается: память выделяется и освобождается автоматически.

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

Чтобы ускорить работу, в CPython придумали собственный менеджер памяти — pymalloc. Его принцип простой:

  • память берётся крупными кусками — аренами по 256 КБ;

  • арена делится на пулы по 4 КБ;

  • каждый пул обслуживает блоки одного размера (от 8 до 512 байт).

За счёт такой «матрёшки» Python почти не дёргает систему за память и быстро раздаёт блоки объектам. А ещё используется Global Interpreter Lock (GIL), поэтому внутри не нужно городить сложные мьютексы для синхронизации.

Для примера — вот как в коде визуализатора определяется таблица «размеров блоков» (классы размеров). Это прямая отсылка к логике obmalloc.c:

def get_size_classes():
    # Возвращает классы размеров для текущей системы
    if sys.maxsize > 2**32:  # 64-битная система
        return [16 * (i+1) for i in range(32)]  # 16..512 с шагом 16
    else:  # 32-битная система
        return [8 * (i+1) for i in range(64)]  # 8..512 с шагом 8

На 64-битной системе это будут классы 16, 32, 48, …, 512 байт. Если объект по размеру подходит под один из таких классов - он попадает в pymalloc. Если больше 512 байт - память выделяется напрямую у ОС.

Арены, пулы и блоки

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

  • Арена — это большой кусок памяти на 256КБ. Можно думать о ней как о «секторе зала».

  • Пул — деление внутри арены на блоки по 4КБ. Это ряды кресел. Каждый пул обслуживает объекты только одного размера.

  • Блок — конкретное место в пуле. На него садится объект, например строка или число.

Такое деление помогает избегать фрагментации и ускоряет выделение памяти: если нужен блок на 48 байт, он всегда возьмётся из пула с блоками именно этого размера.

Визуализатор MemoryMonitorApp моделирует это довольно прозрачно. Вот как описывается арена в коде:

class PyArena(ctypes.Structure):
    _fields_ = [
        ('pool_address', ctypes.c_void_p),
        ('nfreepools', ctypes.c_uint),
        ('ntotalpools', ctypes.c_uint),
        ('freepools', ctypes.POINTER(ctypes.c_void_p)),
        ('nextarena', ctypes.POINTER(PyArena)),
        ('prevarena', ctypes.POINTER(PyArena))
    ]

Каждая арена знает, сколько у неё всего пулов и сколько из них ещё свободны. Визуализатор рисует её прямоугольником, а внутри - пулы.

А вот как создаётся новый пул в приложении:

def allocate_new_pool(self, cls_idx, arena=None):
    if not arena:
        arena = self.find_available_arena() or self.create_new_arena()

    block_size = self.SIZE_CLASSES[cls_idx]
    blocks_count = self.POOL_SIZE // block_size

    pool = {
        'arena': arena,
        'size_class': cls_idx,
        'blocks': [None] * blocks_count,
        'freeblocks': blocks_count,
        'index': len(arena['pools']),
        'freeblock': {'next': None}
    }

    arena['pools'].append(pool)
    arena['nfreepools'] -= 1
    return self.allocate_from_pool(pool, cls_idx)

Здесь создаётся пул для конкретного «класса размера» (например, блоков по 64 байта). Он сразу делится на блоки и привязывается к арене.

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

Такое деление «арена → пул → блок» и есть сердце pymalloc. Благодаря ему Python быстро раздаёт память мелким объектам и не тревожит лишний раз операционную систему.

Подсчёт ссылок и сборка мусора

Аллокатор памяти - это ещё не всё. Даже если раздать блоки, нужно понимать, когда их освобождать. В CPython за это отвечают два механизма: подсчёт ссылок и сборка мусора (GC).

Подсчёт ссылок

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

В коде визуализатора есть упрощённый способ заглянуть в этот механизм:

def show_ref_counts(self, obj):
    ref_count = sys.getrefcount(obj)
    self.display_stat(f"Ref count for {obj}: {ref_count}")

Например, если сделать:

x = [1, 2, 3]
y = x

— у списка будет как минимум две ссылки. Удалим y - счётчик упадёт, но объект ещё живёт. Удалим и x - на счётчике будет ноль, и список освободится.

Сборка мусора

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

GC в Python работает поколениями: «молодые» объекты проверяются чаще, «старые» реже. Если объект уже пережил несколько циклов, считается, что он «долгоиграющий» и его не стоит часто проверять.

Визуализатор умеет показывать статистику по GC:

def show_gc_stats(self):
    counts = gc.get_count()
    self.display_stat(f"GC generations: {counts}")

Здесь gc.get_count() возвращает три числа - сколько объектов в каждом поколении. А gc.collect() можно вызвать вручную, чтобы сразу почистить циклы.

Мини-эксперимент

Вот простой пример кода:

import gc

def test_cycle():
    x = []
    x.append(x)  # создаём цикл
    del x
    print("Before GC:", gc.get_count())
    gc.collect()
    print("After GC:", gc.get_count())

Без сборщика такой объект завис бы в памяти навсегда. Но GC находит его и освобождает.

MemoryMonitorApp: как устроен визуализатор

Теория про арены и пулы звучит красиво, но хочется видеть это вживую. Именно для этого я собрал небольшое приложение MemoryMonitorApp на PyQt5. Оно не претендует на точность «бит в бит» с CPython, но хорошо показывает основные механизмы.

Общая идея

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

Параметры памяти

Сначала приложение получает основные параметры из CPython:

def get_memory_params():
    return {
        'BLOCK_SIZE': 16 if sys.maxsize > 2**32 else 8,
        'POOL_SIZE': 4 * 1024,
        'ARENA_SIZE': 256 * 1024
    }

Это как раз те самые размеры арены и пула, о которых мы говорили выше.

Аллокация памяти

Самое интересное - как приложение симулирует работу pymalloc.
Вот ключевой метод:

def pymalloc_alloc(self, size):
    if size > 512:
        return self.raw_alloc(size)
    cls_idx = next(i for i, sz in enumerate(self.SIZE_CLASSES) if sz >= size)
    pool = self.find_available_pool(cls_idx)
    if pool is not None:
        return self.allocate_from_pool(pool, cls_idx)
    block = self.allocate_new_pool(cls_idx)
    if block is not None:
        return block
    return self.raw_alloc(size)

Здесь хорошо видно логику:
1. Если объект крупнее 512 байт - он идёт напрямую в «сырое» выделение у ОС.
2. Если меньше - ищем пул подходящего класса.
3. Если пула нет - создаём новый.

Создание объекта

Когда вы нажимаете кнопку «Создать объект» (например, строку или список), вызывается такой код:

def create_object(self, factory, type_name):
    obj = factory()
    size = asizeof(obj)
    block = self.pymalloc_alloc(size)
    if block:
        block['type'] = type_name
        block['size'] = size
        block['obj'] = obj
        self.objects.append(block)
    return block

Здесь библиотека pympler помогает вычислить «глубокий» размер объекта. Потом вызывается pymalloc_alloc, и, если выделение успешно, блок связывается с конкретным Python-объектом.

Визуализация

Каждый блок рисуется в интерфейсе как прямоугольник. Если он занят - подсвечивается своим цветом (список, строка, словарь - у каждого свой). При наведении всплывает подсказка с информацией: тип, размер, значение.

Например, класс для блока:

class MemoryBlock(QGraphicsRectItem):
    def init(self, x, y, width, height, obj_info=None, parent=None):
        super().__init__(x, y, width, height, parent)
        self.obj_info = obj_info
        self.setToolTip(self.create_tooltip())

Это превращает «сухие цифры» в картинку, которую можно щёлкнуть и изучить подробнее.

Эксперименты

Теория и код - это хорошо, но настоящий кайф от визуализатора приходит, когда начинаешь «щупать» память руками. Давайте пройдём три простых сценария, которые хорошо показывают поведение pymalloc и сборщика мусора.

Эксперимент A: как объекты рассаживаются по пулам

Создадим несколько строк и посмотрим, что происходит:

for i in range(10):
     app.create_object(lambda: f"str_{i}", "str")

На экране вы увидите арену, в ней - пул для строк, а в пуле аккуратно займутся первые десять блоков. Остальные блоки этого пула останутся пустыми.

Интересно понаблюдать: если п��одолжить создавать строки, они будут занимать следующие блоки. Новый пул появится только тогда, когда старый полностью заполнится.

Эксперимент B: освобождение и переиспользование

Теперь удалим часть объектов:

for i in range(0, 10, 2):
    block = app.objects[i]
    app.pymalloc_free(block)

Визуализатор подсветит освобождённые блоки как «свободные». Но память арене никто не возвращает: она по-прежнему числится выделенной.

Если теперь создать новые объекты, они займут именно эти свободные блоки. Это наглядно показывает устройство freeblock list - списка свободных мест, куда кладутся новые объекты.

Кусок из кода:

def pymalloc_free(self, block):
    """Освобождение блока"""
    block['obj'] = None
    pool = block['pool']
    pool['freeblocks'] += 1
    block['free'] = True

Эксперимент C: циклы и сборщик мусора

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

def make_cycle():
    x = []
    x.append(x)
    return x
 
app.create_object(make_cycle, "list_cycle")

Счётчик ссылок у такого списка никогда не упадёт до нуля: он сам себя удерживает. Но GC его видит.

Если вызвать:

import gc
gc.collect()

— визуализатор покажет, что объект освободился, а блок вернулся в список свободных.

Эти три сценария дают базовое понимание:

  1. Объекты не раскиданы хаотично, а упорядоченно рассаживаются по пулам.

  2. Освобождение не возвращает память системе, а лишь помечает блоки как свободные.

  3. Циклы - особый случай, который решает GC.

Инструменты отладки и измерения

Визуализатор хорош, когда нужно «увидеть картинку». Но в реальном коде полезнее иметь инструменты, которые можно включить прямо в проект и собирать статистику. У Python есть несколько таких встроенных помощников.

tracemalloc

Этот модуль умеет делать «снимки памяти». Он не просто говорит «занято столько-то мегабайт», а показывает, где именно произошли выделения.

Простейший пример:

import tracemalloc
 
tracemalloc.start()

# создаём немного объектов
data = [str(i) for i in range(10000)]

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

for stat in top_stats[:5]:
   print(stat)

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

gc

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

import gc
print(gc.get_count())  # например: (700, 12, 0)

А ещё можно включить отладочный режим, чтобы видеть, что именно собирается:

gc.set_debug(gc.DEBUG_LEAK)
gc.collect()

pympler

Библиотека pympler помогает узнать реальный размер объекта. В отличие от sys.getsizeof(), она учитывает вложенные структуры.

from pympler.asizeof import asizeof
 
a = [1, 2, [3, 4]]
print(asizeof(a))  # размер со всеми вложенными элементами

Визуализатор как раз использует asizeof, чтобы решить, поместится объект в pymalloc или пойдёт напрямую в «raw» выделение.

Эти три инструмента дают возможность заглянуть в память без графики: tracemalloc - для поиска источников аллокаций, gc - для анализа циклов, pympler - для измерений размеров объектов.

Связь с исходниками CPython

Визуализатор показывает принципы работы памяти на Python-объектах, но интересно сравнить его с реальным кодом в CPython. Основная логика аллокатора находится в файле Objects/obmalloc.c. Именно там реализован pymalloc.

Арены и пулы в C

В исходниках можно найти структуру арены:

struct arena_object {
     uptr address;                 /* начало арены /
     block pool_address;          /* указатель на первый пул /
     uint nfreepools;              / количество свободных пулов /
     uint ntotalpools;             / всего пулов /
     struct arena_object nextarena;
     struct arena_object* prevarena;
 };

Она почти один в один совпадает с тем, что визуализатор показывает в Python через ctypes. ### Размеры блоков

В obmalloc.c жёстко зашиты классы размеров блоков:

#define ALIGNMENT           16
#define SMALL_REQUEST_THRESHOLD 512
#define NB_SMALL_SIZE_CLASSES   64

То есть все запросы меньше 512 байт идут через pymalloc. А количество классов зависит от архитектуры (32 или 64 бита). В визуализаторе это воспроизведено функцией get_size_classes().

Аллокация в реальном CPython

В коде CPython логика похожа на ту, что мы видели в Python-версии:

  1. Если размер больше 512 байт вызов системного malloc.

  2. Иначе - поиск подходящего пула.

  3. Если пула нет - создание нового внутри арены.

Разница в том, что в obmalloc.c всё написано на C и оптимизировано под производительность, а в визуализаторе - в упрощённой форме для наглядности.

Таким образом, приложение повторяет ключевые механизмы CPython, только в упрощённом виде: структуры арены и пула, таблицы классов размеров, логику выделения и освобождения. Это помогает связать сухой C-код из obmalloc.c с понятной картинкой на экране.

Проблемы и оптимизации

Когда смотришь на визуализатор, становится заметно, что у системы есть свои «подводные камни». Даже если всё работает как задумано, иногда возникают эффекты, которые новичка могут удивить.

Фрагментация арен

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

Неосвобождение памяти системе

Даже если вы освободили все блоки, арена может остаться зарезервированной. CPython предпочитает не отдавать память обратно ОС слишком активно: дорого заново просить и инициализировать. Поэтому после пика нагрузки процесс может продолжать «занимать» память, хотя по факту большинство блоков пусты.

Циклы и сюрпризы от GC

GC отлично собирает циклы, но не всегда вовремя. Если у вас код, который быстро создаёт и рвёт связи между объектами, циклы могут копиться до тех пор, пока GC не запустится. Иногда помогает ручной вызов gc.collect(), но злоупотреблять им не стоит.

Советы бывалого

  • «Не верь top». Увидели, что ваш процесс жрёт гигабайт, хотя вы освободили все списки? Не спешите паниковать: скорее всего, это арены, которые пока не отданы системе.

  • «Пул любит компанию». Создаёте много мелких объектов одного размера - они плотно усядутся в одном пуле. Но если размеры чуть разные, пулы будут плодиться. Иногда выгоднее нормализовать данные.

  • «GC — это не пылесос». Он не крутится каждую секунду. Если у вас временные циклы - они могут пожить дольше, чем вы ожидали.

  • «Фри — не значит свобода». Освободили объект - блок стал свободным, но арена всё ещё с вами, так что «сэкономил память» - это громко сказано.

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

Как запустить визуализатор

Шаг 1. Создать окружение через uv

uv venv
source .venv/bin/activat

Шаг 2. Установить зависимости

uv sync

Шаг 3. Запустить приложение

python memory_monitor.py

Заключение

Память в Python устроена сложнее, чем может показаться на первый взгляд. Вместо привычного «malloc/free» здесь работает собственный менеджер - pymalloc, который распределяет память через арены, пулы и блоки. Добавим сюда подсчёт ссылок и сборщик мусора - и получаем довольно изощрённый, но эффективный механизм.

Визуализатор MemoryMonitorApp позволяет наглядно увидеть всё это в действии: где именно выделяются объекты, как блоки переходят в свободные, почему память иногда не возвращается системе и как работают циклы. Это не промышленный инструмент, а скорее «лупа», через которую удобно рассматривать устройство CPython.

Попробуйте сами: это лучший способ прочувствовать то, что обычно скрыто внутри интерпретатора.

Источники

Комментарии (0)