__slots__ в Python под нагрузкой: сколько памяти экономит
Сервис держит в памяти миллионы мелких объектов, RSS растёт до нескольких гигабайт. __slots__ убирает __dict__ у экземпляра и режет расход памяти в разы — но ломает часть привычного поведения.

Кэш держит в памяти пять миллионов объектов Point с тремя числовыми полями. RSS процесса — около 2,3 ГБ, GC периодически замирает на сотни миллисекунд, инстанс упирается в лимит контейнера. Профилировщик показывает: сами данные занимают меньше половины, остальное — накладные расходы на объекты Python. Первый кандидат на оптимизацию таких структур — __slots__. Ниже — что именно он экономит, на каких данных, и что перестаёт работать, когда вы его включаете. Замеры сделаны на CPython 3.12, x86-64.
Откуда берётся расход памяти
Обычный экземпляр пользовательского класса хранит атрибуты в словаре __dict__. Словарь — это отдельный объект с собственной хеш-таблицей, и он живёт у каждого экземпляра. Плюс сам заголовок объекта (счётчик ссылок и указатель на тип), плюс указатель на __weakref__. Для класса с тремя полями большую часть занятой памяти съедает не тройка чисел, а инфраструктура вокруг них.
__slots__ говорит интерпретатору: у экземпляров этого класса будет фиксированный набор атрибутов, храни их в компактном массиве слотов прямо в объекте, а __dict__ не создавай вовсе.
class PointDict:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
class PointSlots:
__slots__ = ("x", "y", "z")
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
Разница в объявлении — одна строка. Разница в памяти на большом объёме — кратная.
Как измерить, а не поверить на слово
Цифры расхода памяти зависят от версии CPython, разрядности и того, что лежит в полях. Не берите чужие числа как истину — снимите свои. Простой замер полного размера коллекции экземпляров: считаем sys.getsizeof каждого объекта плюс размер атрибутов, а для честной картины смотрим на RSS процесса.
import sys
import tracemalloc
def make_dict(n):
return [PointDict(i, i + 1, i + 2) for i in range(n)]
def make_slots(n):
return [PointSlots(i, i + 1, i + 2) for i in range(n)]
N = 1_000_000
tracemalloc.start()
objects = make_dict(N)
current, peak = tracemalloc.get_traced_memory()
print(f"dict: {current / 1024 / 1024:.1f} MiB")
del objects
tracemalloc.stop()
tracemalloc.start()
objects = make_slots(N)
current, peak = tracemalloc.get_traced_memory()
print(f"slots: {current / 1024 / 1024:.1f} MiB")
tracemalloc.stop()
tracemalloc считает память, выделенную аллокатором Python — это точнее, чем разность RSS, потому что не ловит фрагментацию и не мешает шум от интерпретатора. Запустите на своих данных: если поля — не числа, а строки или вложенные объекты, экономия на слотах будет меньше, потому что сами значения весят больше, чем инфраструктура вокруг них.
Слоты экономят на форме объекта, а не на его содержимом. Чем легче хранимые данные, тем заметнее выигрыш. Для класса с тремяintэкономия велика, для класса с полем на мегабайтныйbytes— в пределах погрешности.
Что показывает замер по порядку величины
Точные байты назвать не берусь — они зависят от сборки CPython. Но структура выигрыша устойчива и её легко воспроизвести самостоятельно замером выше:
- у версии с
__dict__на каждый экземпляр добавляется отдельный словарь — это десятки байт сверх заголовка объекта и полей; - у версии со слотами словаря нет, атрибуты лежат подряд в теле объекта, и на миллионах мелких экземпляров разница выходит в разы, а не на проценты;
- начиная с CPython 3.11 у обычных экземпляров появился разделяемый ключ для
__dict__, что немного сгладило разрыв, но не убрало его.
Смотрите не только на tracemalloc, но и на RSS через ps или /proc/<pid>/status под реальной нагрузкой: аллокатор не всегда возвращает освобождённую память ОС, и на графиках мониторинга картина может отличаться от лабораторного замера.
Что перестаёт работать
Слоты — не бесплатная галочка. Они меняют поведение класса, и часть привычных вещей ломается.
Нельзя добавить атрибут на лету
Присвоение атрибута, которого нет в __slots__, поднимает AttributeError. Именно за этим слоты часто и включают — но код, который динамически докидывает поля (например, кэширует вычисленное значение в self._cache), сломается.
p = PointSlots(1, 2, 3)
p.color = "red"
# AttributeError: 'PointSlots' object has no attribute 'color'
Наследование: слот работает не так, как кажется
Если хотя бы один класс в иерархии не объявил __slots__, у экземпляров всё равно появится __dict__, и экономия исчезнет. Чтобы слоты работали по всей цепочке, их должен объявить каждый класс. Дублировать имена родителя в __slots__ потомка не нужно и вредно — это создаёт скрытые дубли слотов.
Пустой __slots__ у базового класса
Частый приём: базовый класс без своих полей объявляет __slots__ = (). Так он не тащит __dict__ в детей.
class Base:
__slots__ = () # не даёт появиться __dict__ у наследников
class PointSlots(Base):
__slots__ = ("x", "y", "z")
Weakref и pickle
Без __slots__ на экземпляр можно создать слабую ссылку. Со слотами — нет, пока вы явно не добавите "__weakref__" в набор. Это важно, если объекты попадают в weakref.WeakValueDictionary или в кэш на слабых ссылках.
class PointSlots:
__slots__ = ("x", "y", "z", "__weakref__")
Сериализация через pickle работает и со слотами, но если вы полагаетесь на копирование __dict__ в своём __getstate__, его больше нет — переопределяйте __getstate__/__setstate__ под слоты.
Слоты и скорость доступа
Помимо памяти, доступ к атрибуту через слот проходит по фиксированному смещению, а не через поиск в словаре. На горячих циклах чтения это даёт небольшой выигрыш по времени. Но не ждите чудес: разница обычно в единицах процентов, и она теряется на фоне любой реальной работы. Слоты берут ради памяти, ускорение — приятный побочный эффект, а не причина.
Когда слоты не нужны
- Экземпляров немного — десятки, сотни, тысячи. Экономия в мегабайты не стоит потери гибкости.
- Классу нужны динамические атрибуты, декораторы, которые дописывают поля, или библиотеки, полагающиеся на
__dict__. - Данные тяжёлые: если поля — большие строки или вложенные структуры, доля инфраструктуры мала и слоты почти ничего не сэкономят.
Альтернативы, если слоты не подходят
Для больших однородных наборов чисел массив объектов — вообще не лучший инструмент. Колоночное хранение через array.array, numpy или pandas уберёт накладные расходы на объекты целиком: миллион чисел там занимает восемь мегабайт, а не сотни. @dataclass(slots=True) (доступно с Python 3.10) даёт слоты и удобный синтаксис одновременно — если вам нужны именно объекты, а не колонки.
from dataclasses import dataclass
@dataclass(slots=True)
class Point:
x: int
y: int
z: int
Что делать дальше
Снимите tracemalloc и RSS до и после включения слотов на своих данных и своём объёме — цифры из чужой статьи не заменят замер на вашей нагрузке. Проверьте, не полагается ли код на __dict__, weakref или динамические атрибуты, прежде чем раскатывать на прод. Если объекты хранят однородные числа и их миллионы — прежде чем возиться со слотами, прикиньте, не выгоднее ли колоночное хранение: оно бьёт слоты на порядок. И держите под наблюдением график памяти после релиза: аллокатор возвращает память ОС не сразу, и реальный эффект виден только под настоящим трафиком.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: Python Language Reference — __slots__, dataclasses — slots parameter
Частые вопросы
Экономия слотов одинакова во всех версиях Python?
Что делать, если после включения слотов код падает с AttributeError?
Почему у меня RSS не упал, хотя tracemalloc показывает экономию?
Слоты сильно ускоряют программу?
Можно ли сериализовать объекты со слотами через pickle?
Стоит ли использовать __slots__ для класса с несколькими экземплярами?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.