Память и объекты: почему int стоит 28 байт и где на самом деле живёт утечка
Каждый объект платит 16-байтный заголовок: int 28 Б, пустой dict 64 Б. getsizeof поверхностен — контейнеры считают указатели, не содержимое. Refcount освобождает мгновенно; циклам нужен поколенческий GC. Диффы tracemalloc находят место роста; большинство утечек — достижимые кэши.
Сервис рекомендаций набирал 80 МБ RSS в день, пока 6-гигабайтный под не OOM-убивался примерно каждые девять дней. Опс «починил» это ночными рестартами на квартал — пока рестарт не совпал с пиковым трафиком и не разбудил всех пейджем. Охота исходила из утечки: gc.collect(), выведенный на дебаг-эндпоинт, освободил два мегабайта. В сишном смысле не текло ничего; всё было достижимо. Дифф снапшотов tracemalloc между первым и тридцатым часом указал на одну строку: @lru_cache(maxsize=None) на score_user(user_id, context_key) — а context_key включал временной бакет, так что почти каждый вызов чеканил свежий ключ. Хитрейт кэша: 0.4%. «Утечкой памяти» оказался кэш, который не мог вытеснять, добавлявший по жирному графу Python-объектов на запрос — навсегда. Один ограниченный maxsize спустя RSS выровнялся на 1.9 ГБ, а ночные рестарты удалили.
Почему объекты Python толстые
Прежде чем искать утечку памяти, нужно понять, за что именно платишь. Когда знаешь стоимость заголовка каждого типа, цифры RSS перестают быть загадкой и становятся арифметикой.
Каждый Python-объект на 64-битной сборке начинается с 16-байтного заголовка: 8 байт счётчика ссылок и 8 байт указателя на тип. Payload — после. Бытовые числа:
import sys
sys.getsizeof(1) # 28 — заголовок + размер + одна 30-битная цифра
sys.getsizeof(1.0) # 24 — заголовок + один C double
sys.getsizeof("a") # 50 — заголовок + флаги + длина + ASCII-буфер
sys.getsizeof({}) # 64 — пустой dict, растёт скачками степеней двойки
sys.getsizeof([]) # 56 — пустой listЭто попредметная цена динамизма: каждое значение в куче, типизировано в рантайме, пересчитывается по ссылкам на каждом связывании. C-шный double — 8 байт в регистре; Python-float — 24 байта в куче плюс 8-байтный указатель, который кто-то держит, чтобы до него добраться. Умножьте на десять миллионов — и счёт за динамизм приходит гигабайтами.
getsizeof лжёт поверхностно
sys.getsizeof сообщает собственную аллокацию одного объекта — для контейнеров это таблица указателей, не содержимое. Список из миллиона float: getsizeof(lst) говорит около 8 МБ (миллион 8-байтных слотов плюс заголовок), тогда как реальный след — эти 8 МБ плюс 24 МБ объектов-float: минимум 32 МБ, больше, когда голосует фрагментация аллокатора. Честный размер рекурсивен: обход gc.get_referents с множеством посещённых — или профилировщик кучи вместо арифметики.
sys.getsizeof на списке из 1 млн различных float возвращает около 8 МБ. Сколько памяти процесса список реально объясняет?
Подсчёт ссылок — и когда отрабатывает циклический сборщик
CPython освобождает объект в момент, когда его refcount достигает нуля — детерминированно, немедленно; поэтому файловые дескрипторы в аккуратном коде закрываются вовремя. Чего refcount освободить не может — цикл: родитель и потомок, держащие ссылки друг на друга, нуля не достигнут никогда. Это работа циклического GC: три поколения, срабатывание по порогам (чистым аллокациям), выжившие повышаются. Циклы копятся в графах с обратными ссылками — деревья, чьи узлы указывают на родителя, ORM-строки, указывающие на сессию, и классический спящий агент: сохранённое исключение держит traceback, traceback держит каждый фрейм, а каждый фрейм — все свои локальные переменные.
gc.collect() — диагностика не меньше, чем лекарство: если принудительная полная сборка почти не сдвигает память — те два мегабайта из хука, — ваш рост достижим, и никакой сборщик мусора вас не спасёт. Утечка — это структура данных, делающая ровно то, что ей велели.
slots ещё раз: рычаг для числа объектов
Юнит о модели данных дал механизм; вот арифметика ёмкости. Пятиполевой обычный экземпляр стоит примерно 250+ байт, как только существует его __dict__; слотированный — около 104. На 1 млн объектов это ~250 МБ против ~104 МБ, а на 40 млн — разница между подом, который влезает, и подом, который OOM-ится. @dataclass(slots=True) сводит это к одному флагу. Слоты — правильный рычаг, когда проблема в числе объектов, а набор полей фиксирован.
Списки объектов против array против numpy: таблица порядков величины
Когда данные числовые и высококардинальные, выигрыш — перестать иметь объекты вообще:
| Представление (1 млн значений float64) | Память (примерно) |
|---|---|
list из Python-float | ~32 МБ (8 указатели + 24 объекты) |
array.array("d") | ~8 МБ, непрерывно |
numpy.ndarray float64 | ~8 МБ, непрерывно + векторизуемо |
| 1 млн обычных объектов с 3 полями | ~250 МБ |
| 1 млн слотированных объектов с 3 полями | ~104 МБ |
| numpy (1 млн, 3) float64 | 24 МБ |
Непрерывный типизированный буфер в 4-10 раз меньше и дружит с кэшем; список с погоней за указателями и толст, и медленен в обходе. Эта таблица — ещё и мост к следующему уроку: numpy — это C-расширение, которое кто-то уже написал.
tracemalloc: найти место аллокации, а не симптом
RSS говорит, что память выросла; tracemalloc — какая строка её выделила. Запускайте рано (глубина фреймов стоит памяти и CPU, так что включайте на время охоты, не навсегда), снимите два снапшота, продиффуйте:
import tracemalloc
tracemalloc.start(25) # хранить 25 фреймов на место аллокации
s1 = tracemalloc.take_snapshot()
# ... 30 минут репрезентативного трафика ...
s2 = tracemalloc.take_snapshot()
for stat in s2.compare_to(s1, "lineno")[:10]:
print(stat) # рост, ранжированный по выделяющей строкеpy-spy этого не умеет — он сэмплирует CPU-стеки, не аллокации. Для нативного + Python-учёта аллокаций есть memray, более тяжёлый сторонний вариант; tracemalloc — инструмент stdlib, который есть везде.
Паттерны утечек, которые не утечки
Повторяющиеся злодеи — всё это достижимый рост: модульные кэши и реестры, которые только дописывают; lru_cache(maxsize=None) с высококардинальными ключами; замыкания и сохранённые исключения, пришпиливающие фреймы (и каждую локальную переменную в них); только-растущие списки на долгоживущих синглтонах. Настоящие C-уровневые утечки существуют, но в чистом Python редки. Когда RSS растёт, первый вопрос не «что утекло», а «какая структура данных слишком хорошо делает свою работу».
Сервис растёт на 60 МБ/час. Принудительный gc.collect() почти ничего не освобождает. О чём говорит этот результат и какой инструмент следующий?
- 01Пройдите триаж сервиса с ровно растущим RSS: какой инструмент отвечает на какой вопрос и что означают возможные результаты?
- 02Назовите честные числа памяти: цена заголовка, размеры типовых объектов и сравнения list-array-numpy и обычный-слоты на 1 млн элементов.
История памяти Python — это попредметная цена динамизма: 16-байтный заголовок из refcount (счётчик ссылок) и указателя типа перед каждым значением делает int 28-байтным, а пустой dict 64-байтным, и каждый контейнер держит указатели на объекты кучи, а не сами значения — поэтому getsizeof поверхностен по замыслу, и список на миллион float реально стоит ~32 МБ против ~8 МБ, которые он сообщает. Освобождение — две системы: refcount детерминированно освобождает при нуле, а поколенческий циклический сборщик существует только ради циклов ссылок — графов с обратными связями, сохранённых исключений, пришпиливающих целые цепочки фреймов. Диагностический шарнир — gc.collect(): если принудительная полная сборка ничего не освобождает, рост достижим, и виновник — структура, делающая свою работу: неограниченный lru_cache, модульный реестр, — которую дифф снапшотов tracemalloc пришпилит к выделяющей строке; CPU-сэмплеру py-spy это недоступно. Рычаги ёмкости масштабируются порядками: __slots__ примерно вдвое режет объекты с фиксированными полями (250 против 104 байт на пяти полях), а полный выход из объектов в array или numpy даёт 4-10x плюс локальность кэша. Теперь, когда видишь ровно растущий RSS в сервисе, который не падает, — первый вопрос не «что течёт», а «какая структура данных слишком хорошо делает свою работу и где перестала быть ограниченной».
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.