open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 08 · 02

Память и объекты: почему int стоит 28 байт и где на самом деле живёт утечка

Каждый объект платит 16-байтный заголовок: int 28 Б, пустой dict 64 Б. getsizeof поверхностен — контейнеры считают указатели, не содержимое. Refcount освобождает мгновенно; циклам нужен поколенческий GC. Диффы tracemalloc находят место роста; большинство утечек — достижимые кэши.

PY Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Сервис рекомендаций набирал 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) float6424 МБ

Непрерывный типизированный буфер в 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() почти ничего не освобождает. О чём говорит этот результат и какой инструмент следующий?

Вспомните перед уходом
  1. 01
    Пройдите триаж сервиса с ровно растущим RSS: какой инструмент отвечает на какой вопрос и что означают возможные результаты?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.