Распределение памяти в Python: сколько и в каких случаях занимают типы данных
Идея статьи возникла после просмотра одного видео, где автор разбирает различные способы создания списка из одинаковых элементов. Меня заинтересовала эта тема, и я начал углубляться в нее. В частности, почему в том или ином случае объем занимаемой памяти отличается.
В этом материале разберем, как устроено выделение памяти под объекты в Python. Потом кратко о том, как работает очистка памяти от неиспользуемых объектов. И, наконец, о разнице в занимаемой памяти на примере типов list, dict и tuple.
Выделение памяти
Напрямую из кода память не выделяется. Вся работа по выделению памяти перекладывается на менеджеров памяти. Есть один общий менеджер «верхнего» уровня, который отвечает за выделение большого блока из выделенной программе памяти — «арена». Занимает 256Кб.
Далее арена делится на «пулы» по 4Кб. Каждый пул может содержать в себе только блоки заранее определенного для этого пула размера — от 8 байт до 512 байт. Арена может содержать в себе пулы разных размеров, а вот блоки в одном пуле всегда одного размера. И вот на уровне блоков работают менеджеры памяти каждого конкретного типа данных.
Когда менеджер определенного типа запрашивает память для объекта, он заранее знает размер и может сразу обратиться к пулу с нужным размером блоков, разместив объект в первом свободном блоке. Если же свободных блоков нет или же пулов нужного размера нет, верхний менеджер выдает новый пул из наиболее заполненной арены. Если и все арены заняты, запрашивается новая арена.
Интересно, что частично вернуть выделенную под арену память нельзя, пока в ней есть хоть один непустой пул, в котором есть хоть один непустой блок. Как блоки становятся пустыми, мы обсудим в следующем разделе.
Освобождение памяти
В Python нет необходимости в ручной очистке памяти. Если объект больше не используется, все это перекладывается на сам интерпретатор и два механизма: счетчик ссылок и сборщик мусора.
Когда переменной присваивается значение, на самом деле сначала создается объект в подходящем свободном блоке (как работает выделение памяти разобрались в прошлом разделе) и потом уже в переменную кладется ссылка на этот объект.
Счетчик ссылок
Каждый созданный объект имеет специальное поле — счетчик ссылок. Он хранит в себе количество ссылающихся на него объектов. Увеличивает свое значение, например, когда используется операция присваивания, или когда объект становится частью списка. При удалении переменной или же при использовании del счетчик ссылок уменьшается на 1. Например, при завершении работы функции, где эта переменная была объявлена.
Разберем на примере небольшой кусок кода:
В данном случае 1000 — это неизменяемый объект, один на всю программу. После инициализации двух переменных счетчик ссылок равен 5.
Почему 5? Потому что на объект 1000 ссылаются не только эти две переменные, а все переменные со значением 1000 во всех используемых модулях. Далее удаляем переменную b и счетчик ссылок меняет свое значение на 4. Как только счетчик достигает 0, объект освобождает блок.
Сборщик мусора
У счетчика ссылок есть большой недостаток — невозможность отловить циклические ссылки. Если объект или несколько объектов ссылаются друг на друга, счетчик никогда не опустится ниже 1.
Специально для обработки таких случаев был создан модуль gc. Его работа заключается в том, чтобы периодически сканировать объекты контейнерного типа и определять наличие циклических ссылок.
Говоря о сборщике мусора, важным понятием является поколение объектов — это набор объектов, за которыми следит, а в последующем сканирует сборщик. Есть три поколения, каждое из которых сканируется с разной частотой. Чаще сканируются объекты первого поколения, т.к. туда попадают новые объекты. Как правило, такие объекты имеют маленький срок жизни и являются временными. Поэтому целесообразно их проверять больше. Если объекты прошли сканирование сборщика мусора и не были удалены, то переходят в следующее поколение.
Сканирование первого поколения начинается, когда количество созданных объектов контейнерного типа превышает количество удаленных на заданный порог. Например, сканирование второго поколения начнется, когда количество сканирований первого поколения превысит заданный порог. По умолчанию, пороги срабатывания — это 700, 10 и 10, соответственно.
Посмотреть их можно через gc.get_threshold(). Изменить через gc.set_threshold().
Все это актуально для list, dict, tuple и еще для классов. Не для простых типов.
Что там по типам данных
Начнем сравнения с list. Поскольку он изменяемый и будет интересно посмотреть, как он ведет себя при изменении количества элементов. Список представляет из себя массив не фиксированного размера, с возможностью произвольной вставки или удаления любого элемента. Элементом списка может быть любой тип данных. С точки зрения хранения списка в памяти, он состоит из двух блоков. Один блок фиксированного размера и хранит информацию о списке. Другой же хранит ссылки на элементы и может переходить из блока в блок, если количество элементов меняется.
Размер занимаемого объектом блока памяти можно посмотреть через sys.getsizeof().
Как мы видим, пустой объект списка уже занимает 64 байта.
При обычном создании списка через перечисление элементов он всегда будет занимать: 64 байта пустой список + 8 байт на каждый элемент, т.к. список представляет из себя массив ссылок на объекты.
При создании через list comprehension размер будет уже 96. Что больше, чем размер пустого списка + 8 байт на каждый элемент. Работа этого механизма сводится к вызову метода append у создаваемого объекта списка. append работает следующим образом: в зависимости от уже присутствующего в списке количества элементов он заранее резервирует больше памяти при добавлении элемента. Дополнительно выделяемый объем памяти не всегда увеличивает список вдвое. Может быть выделено место всего под несколько элементов. Например, если к списку из 8-ми элементов добавляют еще один, будет зарезервировано место еще под восемь элементов. А при добавлении к списку из 16-ти элементов будет зарезервировано место всего под 9 элементов. Это позволяет избежать затрат на изменение размера списка при частых вызовах append. При этом неиспользуемая, но уже выделенная, память недоступна для чтения.
При создании списка на основе кортежа получившийся список будет занимать 112 байт. В данном случае заранее резервируется место под еще 3 элемента списка, помимо уже присутствующих в кортеже.
Итого получаем 64 байта, + 8 * 3 — это элементы из кортежа, + 8 * 3 зарезервированное место под новые элементы.
Tuple
Кортеж представляет из себя массив фиксированной длины, заданной при создании объекта. Элементами кортежа также могут быть объекты любых типов. В отличие от списка, кортеж в памяти представлен одним объектом. Поскольку нет изменяемой части, которую надо перемещать между блоками. Да, и методов для изменения элементов у кортежа так же нет. Но если сам элемент принадлежит к изменяемому типу, его все же можно изменить.
Пустой кортеж занимает блок из 48 байт. Помним, что кортежи неизменяемы. Если в памяти уже есть такой же кортеж, то новый объект создан не будет. Вместо этого переменной присваивается ссылка на существующий объект.
На примере видно, что адреса в памяти у двух кортежей совпадают. Аналогичное сравнение для списков вернуло бы False.
В случае непустого кортежа с памятью так же все просто. Объем блока будет равен 48 байтам + по 8 байт на каждый элемент.
С созданием кортежа из списка или другой коллекции тоже нет таких неожиданностей, как со списком. Т.к. не надо закладывать место под изменение количества элементов.
Словарь представляет из себя массив ключей и массив значений, где каждый ключ связан с одним значением. На ключ накладывается ограничение по уникальности в пределах словаря. Поэтому ключами могут быть объекты только неизменяемых типов. Значением же может быть объект любого типа.
Как и списки, словари хранятся в виде двух объектов. Первый, содержит информацию о самом словаре и всегда остается в одном и том же блоке. Второй, хранит пары ключ-значение и может перемещаться между блоками при изменении размера. Но при этом пустой словарь занимает гораздо больше места.
Как видно в примере, словарь занимает 240 байт. При создании словаря выделяется место под несколько элементов, а не только после добавления элемента, как это было со списком.
Если вызвать метод clear, очищаются не только все элементы. Изначально зарезервированная память тоже будет освобождена.
В итоге словарь стал занимать всего 72 байта. Гораздо меньше, чем при создании.
Как и со списком, на каждую новую пару ключ-значение весь объект не перемещается в новый блок. Чтобы избежать частых затрат на перемещение, новый блок берется с запасом на несколько элементов.
Заключение
Знать, какой объем памяти будет занимать тот или иной объект, полезно. Например, в условиях проекта или задачи указаны жесткие ограничения по памяти. Или кусок кода вызывается очень часто, необходимо понять, не будет ли выделение/освобождение памяти являться узким местом вашей системы.
Поэтому рекомендую, прикидывать примерные затраты памяти, еще на момент написания кода. И никто не отменяет переиспользование уже существующих объектов.
Если вы заинтересовались этой темой и решили углубиться в нее, то обратите внимание на следующие материалы:

— Memory management in Python. Тут подробно описан механизм работы менеджеров памяти и организации блоков.
— Memory Management. Не забываем про официальную документацию.
— Garbage Collection for Python. Здесь детальнее рассказывают про алгоритм работы сборщика мусора.
Управление памятью в Python
Вы когда-нибудь задумывались, как Python обрабатывает ваши данные за кулисами? Как ваши переменные хранятся в памяти? Когда они удаляются?
В этом уроке мы собираемся глубоко погрузиться во внутреннее устройство Python и понять, как он управляет памятью.
К концу этого урока вы:
- Узнайте больше о низкоуровневых вычислениях, в частности то, что касается памяти.
- Поймёте, как Python абстрагирует операции нижнего уровня.
- Узнайте об алгоритмах управления внутренней памятью Python.
Понимание внутреннего устройства Python также поможет вам лучше понять некоторые особенности поведения Python. Надеюсь, вы тоже по-новому оцените Python.
За кулисами творится так много и, чтобы ваша программа работала так, как вы ожидаете эту логику надо понимать.
Содержание
Память — это чистая книга ↑
Вы можете начать с представления памяти компьютера как о чистой книге, предназначенной для рассказов. На страницах еще ничего не написано. Со временем появятся разные авторы. Каждому автору нужно место для написания своей истории.
Поскольку им не разрешается писать друг над другом, они должны быть осторожны с тем, на каких страницах они пишут. Прежде чем начать писать, они консультируются с редактором. Затем редактор решает, где в книге им разрешено писать.
Поскольку эта книга существует уже давно, многие рассказы в ней уже не актуальны. Когда никто не читает и не ссылается на истории, они удаляются, чтобы освободить место для новых историй.
По сути, компьютерная память похожа на эту пустую книгу. Фактически, принято называть непрерывные блоки страниц памяти фиксированной длины, так что эта аналогия вполне уместна.
Авторы похожи на разные приложения или процессы, которым нужно хранить данные в памяти. Редактор, который решает, где авторы могут писать в книге, играет роль своего рода диспетчера памяти. Человек, который удалил старые истории, чтобы освободить место для новых, — это сборщик мусора.
Управление памятью: от оборудования к программному обеспечению ↑
Управление памятью — это процесс, с помощью которого приложения читают и записывают данные. Диспетчер памяти определяет, куда поместить данные приложения. Поскольку объем памяти ограничен, как и страницы в нашей книжной аналогии, менеджер должен найти свободное место и предоставить его приложению. Этот процесс предоставления памяти обычно называется выделением памяти.
С другой стороны, когда данные больше не нужны, их можно удалить или освободить. Но как освободить? Откуда взялась эта «память»?
Где-то на вашем компьютере есть физическое устройство, хранящее данные, когда вы запускаете свои программы Python. Однако, существует множество уровней абстракции, через которые проходит код Python, прежде чем объекты фактически попадут на оборудование.
Один из основных уровней над оборудованием (например, ОЗУ или жесткий диск) — это операционная система (ОС). Она выполняет (или отклоняет) запросы на чтение и запись в память.
Над ОС есть приложения, одно из них — интерпретатор Python по умолчанию (включенный в вашу ОС или загруженный с python.org). Управление памятью для вашего кода Python обрабатывается приложением Python. В этом уроке основное внимание уделяется алгоритмам и структурам, которые приложение Python использует для управления памятью.
Интерпретатор Python по умолчанию ↑
Интерпретатор Python по умолчанию, CPython, фактически написан на языке программирования C.
Когда я впервые это услышал, то поразился. Язык, которой написан на другом языке?! Ну, не совсем так, но вроде как.
Язык Python определен в справочном руководстве, написанном на английском языке. Однако само по себе это руководство не так уж и полезно. Вам все еще что-то нужно для интерпретации написанного кода на основе этих правил в руководстве.
Вам также что-то нужно для реального выполнения интерпретируемого кода на компьютере. Интерпретатор Python по умолчанию удовлетворяет обоим этим требованиям. Он преобразует ваш код Python в инструкции, которые затем запускаются на виртуальной машине.
Примечание. Виртуальные машины похожи на физические компьютеры, но они реализованы программно. Обычно они обрабатывают базовые инструкции, аналогичные инструкциям ассемблера.
Python — это интерпретируемый язык программирования. Ваш код Python фактически компилируется в более машиночитаемые инструкции, называемые байт‑кодом. Эти инструкции интерпретируются виртуальной машиной при запуске кода.
Вы когда-нибудь видели файл .pyc или папку __pycache__? Это байт‑код, который интерпретируется виртуальной машиной.
Важно отметить, что существуют реализации, отличные от CPython. IronPython компилируется для работы в Microsoft Common Language Runtime. Jython компилируется в байт‑код Java для работы на виртуальной машине Java. Затем есть PyPy, но он заслуживает отдельной статьи, поэтому я просто упомяну его вскользь. В этом уроке я сосредоточусь на управлении памятью, осуществляемом стандартной реализацией Python, CPython.
Отказ от ответственности: хотя большая часть этой информации будет перенесена в новые версии Python, в будущем все может измениться. Обратите внимание, что версия, на которую ссылается эта статья, — это последняя версия Python 3.9.
Итак, CPython написан на C и интерпретирует байт‑код Python. При чем здесь управление памятью? Что ж, алгоритмы и структуры управления памятью существуют в коде CPython на C. Чтобы понять управление памятью в Python, вы должны получить базовое представление о самом CPython.
CPython написан на C, который изначально не поддерживает объектно-ориентированное программирование. Из-за этого в коде CPython есть довольно много интересных дизайнов.
Возможно, вы слышали, что все в Python является объектом, даже такие типы, как int и str . Что ж, это верно на уровне интерпретатора CPython. Есть структура под названием PyObject , которая использует любой другой объект в CPython.
Примечание. Struct или структура в C — это настраиваемый тип данных, который группирует вместе различные типы данных. По сравнению с объектно-ориентированными языками это похоже на класс с атрибутами и без методов.
PyObject , прародитель всех объектов в Python, содержит только две вещи:
- ob_refcnt — счетчик ссылок
- ob_type — указатель на другой тип
Счетчик ссылок используется для сборки мусора (garbage collection). Тогда у вас есть указатель на фактический тип объекта. Этот тип объекта — просто еще одна структура, описывающая объект Python (например, dict или int ).Каждый объект имеет свой собственный объектно-зависимый распределитель памяти, который знает, как получить память для хранения этого объекта. У каждого объекта также есть объектно-зависимый механизм освобождения памяти, который «освобождает» память, когда она больше не нужна.
Однако во всех этих разговорах о выделении и освобождении памяти есть важный фактор. Память — это общий ресурс на компьютере, и могут случиться неприятности, если два разных процесса попытаются записать в одно и то же место в одно и то же время.
Глобальная блокировка интерпретатора (GIL) ↑
GIL એ (Global Interpreter Lock) — это решение общей проблемы работы с общими ресурсами, такими как память компьютера. Когда два потока пытаются одновременно изменить один и тот же ресурс, они могут наступить друг другу на пятки. Конечным результатом может быть запутанный беспорядок, когда ни один из потоков не получает то, что он хочет.
Снова рассмотрим аналогию с книгой. Предположим, два автора упорно решают, чья очередь писать. Кроме того, оба одновременно претендуют на одну и ту же страницу книги.
Каждый из них игнорирует попытки другого писать рассказ и начинает писать на этой странице. Конечный результат — две истории накладываются друг над другом, что делает всю страницу совершенно нечитаемой.
Одним из решений этой проблемы является единственная глобальная блокировка интерпретатора, когда потоки взаимодействуют с общим ресурсом (страницей в книге). Другими словами, писать одновременно может только один автор.
GIL Python это обеспечивает, блокируя весь интерпретатор и это означает, что другой поток не может перекрыть текущий. Когда CPython обрабатывает память, он использует GIL, обеспечивая безопасность.
У такого подхода есть свои плюсы и минусы, и GIL активно обсуждается в сообществе Python. Чтобы узнать о GIL больше, предлагаю прочитать, What Is the Python Global Interpreter Lock (GIL)?.
Сборка мусора ↑
Давайте вернемся к аналогии с книгой и предположим, что некоторые из рассказов в книге стали очень старыми и никто больше эти истории не читает, не ссылается на них. Если никто не читает или не ссылается на них в своей работе, можно от них избавиться и освободить место для новой истории.
Эту старую запись без ссылок можно сравнить с объектом в Python, счетчик ссылок которого упал до 0. Помните, что каждый объект в Python имеет счетчик ссылок и указатель на тип.
Счетчик ссылок увеличивается по нескольким причинам. Например, количество ссылок увеличится, если вы назначите его другой переменной:
Он также увеличится, если вы передадите объект в качестве аргумента:
В качестве последнего примера, счетчик ссылок увеличится, если вы включите объект в список:
Python позволяет вам проверять текущее количество ссылок на объект с помощью модуля sys. Вы можете использовать sys.getrefcount(numbers) , но имейте в виду, что передача объекта в getrefcount() увеличивает счетчик ссылок на 1.
В любом случае, если объект по-прежнему должен оставаться в вашем коде, его счетчик ссылок больше 0. Как только он упадет до 0, у объекта будет вызвана особая функция освобождения памяти, которая «освобождает» память, чтобы другие объекты могли её использовать.
Но что значит «освободить» память и как ее используют другие объекты? Давайте сразу перейдем к управлению памятью CPython.
Управление памятью CPython ↑
Мы собираемся углубиться в архитектуру и алгоритмы памяти CPython, так что пристегнитесь.
Как упоминалось ранее, существуют уровни абстракции от физического оборудования до CPython. Операционная система (ОС) абстрагирует физическую память и создает уровень виртуальной памяти, к которому могут обращаться приложения (включая Python).
ОС-конкретный диспетчер виртуальной памяти выделяет часть памяти для процесса Python. Темно-серые прямоугольники на изображении ниже теперь принадлежат процессу Python. 
Python использует часть памяти для внутреннего использования и не объектную память. Другая часть предназначена для хранения объектов ( int , dict и т. д.). Обратите внимание, что это было несколько упрощено. Если вам нужна полная картина, вы можете проверить исходный код CPython, где происходит все это управление памятью. CPython имеет распределитель объектов, который отвечает за выделение памяти в области памяти объектов. В этом распределителе объектов происходит большая часть волшебства. Он вызывается каждый раз, когда новому объекту требуется выделить или удалить пространство.
Обычно, добавление и удаление данных для объектов Python, таких как list и int , не требует одновременного использования слишком большого количества данных. Таким образом, распределитель рассчитан на одновременную работу с небольшими объемами данных. Он также пытается не выделять память до тех пор, пока она не станет абсолютно необходимой.
Комментарии в исходном коде описывают распределитель как “a fast, special-purpose memory allocator for small blocks, to be used on top of a general-purpose malloc.” (быстрый, специальный распределитель памяти для небольших блоков, который будет использоваться поверх универсального malloc). В данном случае malloc — это библиотечная функция языка C для выделения памяти.
Теперь мы рассмотрим стратегию распределения памяти CPython. Сначала мы поговорим о трех основных элементах и о том, как они соотносятся друг с другом. Арены — это самые большие фрагменты памяти, которые выровнены по границе страницы в памяти. Граница страницы — это край непрерывного фрагмента памяти фиксированной длины, который использует ОС. Python предполагает, что размер страницы системы составляет 256 килобайт. 
Внутри арен находятся пулы, которые представляют собой одну страницу виртуальной памяти (4 килобайта). Это как страницы в нашей книжной аналогии. Эти пулы фрагментированы на более мелкие блоки памяти.
Все блоки в данном пуле относятся к одному «классу размера». Класс размера определяет конкретный размер блока с учетом некоторого количества запрошенных данных.Приведенная ниже диаграмма взята непосредственно из комментариев к исходному коду:
| Запрос в байтах | Размер выделенного блока | idx класса размера |
|---|---|---|
| 1-8 | 8 | 0 |
| 9-16 | 16 | 1 |
| 17-24 | 24 | 2 |
| 25-32 | 32 | 3 |
| 33-40 | 40 | 4 |
| 41-48 | 48 | 5 |
| 49-56 | 56 | 6 |
| 57-64 | 64 | 7 |
| 65-72 | 72 | 8 |
| … | … | … |
| 497-504 | 504 | 62 |
| 505-512 | 512 | 63 |
Например, если запрошено 42 байта, данные будут помещены в блок размером 48 байтов.
Пулы (Pools) ↑
Пулы состоят из блоков одного класса размера. Каждый пул поддерживает список с двойной связью с другими пулами того же класса размера. Таким образом, алгоритм может легко найти доступное пространство для блока заданного размера даже в разных пулах.
Список используемых пулов отслеживает все пулы, в которых есть место для данных для каждого класса размера. Когда запрашивается данный размер блока, алгоритм проверяет этот список используемых пулов на наличие списка пулов для этого размера блока.
Сами пулы должны находиться в одном из трех состояний: used , full или empty (используется, заполнен или пуст). В используемом пуле есть доступные блоки для хранения данных. Все блоки полного пула выделены и содержат данные. В пустом пуле нет хранимых данных и при необходимости ему может быть назначен любой класс размера для блоков.
Список свободных пулов отслеживает все пулы в пустом состоянии. Но когда используются пустые пулы?
Предположим, вашему коду требуется фрагмент памяти в 8-байтовый. Если в используемых пулах с размером класса 8 байт нет пулов, новый пустой пул инициализируется для хранения 8-байтовые блоки. Затем этот новый пул добавляется в список используемых пулов, чтобы его можно было использовать для будущих запросов.
Скажем, полный пул освобождает некоторые из своих блоков, потому что память больше не нужна. Этот пул будет добавлен обратно в список используемых пулов для своего класса размера.Теперь вы можете видеть, как пулы могут свободно перемещаться между этими состояниями (и даже классами размера памяти) с помощью этого алгоритма.
Блокировка ↑

Как видно на диаграмме выше, пулы содержат указатель на свои «свободные» блоки памяти. В том, как это работает, есть небольшой нюанс. Согласно комментариям в исходном коде, этот распределитель «стремится на всех уровнях (арене, пуле и блоке) никогда не касаться части памяти, пока она действительно не понадобится».Это означает, что пул может иметь блоки в 3-х состояниях. Эти состояния можно определить следующим образом:
- нетронута – часть памяти, которая не была выделена
- свободна – часть памяти, которая была выделена, но позже освобождена CPython и больше не содержит актуальных и нужных в текущий момент данных.
- распределена – часть памяти, которая фактически содержит нужные сейчас данные
Указатель свободных блоков указывает на односвязный список свободных блоков памяти. Другими словами, список доступных мест для размещения данных. Если требуется больше, чем доступных свободных блоков, распределитель получит несколько нетронутых блоков в пуле.
Поскольку диспетчер памяти освобождает блоки, то теперь свободные блоки добавляются в начало списка свободных блоков. Фактически список не может указывать на непрерывные блоки памяти, как на первой хорошей диаграмме. Это может выглядеть примерно так: 
Арены ↑
Арены содержат pools (бассейны). Эти пулы могут быть использованы, полными или пустыми. Сами арены не имеют таких явных состояний, как пулы.
Вместо этого арены организованы в двусвязный список под названием usable_arenas . Список отсортирован по количеству доступных свободных пулов. Чем меньше свободных пулов, тем ближе арена к началу списка.

Это означает, что для размещения новых данных будет выбрана арена, наиболее заполненная данными. Но почему не наоборот? Почему бы не разместить данные там, где больше всего свободного места?
Это подводит нас к идее освободить память по-настоящему. Вы заметили, что я довольно часто говорю «свободная» в кавычках. Причина в том, что когда блок считается «свободным», то фактически память для операционной системы не освобождается. Процесс Python сохраняет её выделенной за собой и будет использовать позже для новых собственных данных процесса. Освобождение памяти по-настоящему возвращает ее операционной системе для использования.
Арены — единственное, что действительно можно освободить. Так, само собой разумеется, что те арены, которые ближе к пустоте, должны стать пустыми. Таким образом можно по-настоящему освободить этот кусок памяти, уменьшив общий объем памяти, занимаемый вашей программой Python.
Заключение ↑
Управление памятью — неотъемлемая часть работы с компьютерами. Python обрабатывает почти все это за кулисами, к лучшему или к худшему.
Из этом уроке вы узнали:
- Что такое управление памятью и почему это важно.
- Что интерпретатор Python по умолчанию, CPython, написан на Си (язык программирования) એ
- Как структуры данных и алгоритмы работают вместе при управлении памятью CPython для обработки ваших данных
Python абстрагирует многие мелкие детали работы с компьютерами. Это дает возможность для разработки своего кода работать на более высоком уровне, не беспокоясь о том, как и где будут храниться все эти байты.
Опубликовано Вадим В. Костерин
ст. преп. кафедры ЦЭиИТ. Автор более 130 научных и учебно-методических работ. Лауреат ВДНХ (серебряная медаль). Посмотреть больше записей
Выделение памяти в C
![]()
В языке C, Операционная Система должна выделить память переменным до того как их можно будет использовать. Всего есть три механизма выделения памяти в C:
Статическое выделение памяти — когда требования к выделяемому месту в памяти определяются в процессе компилирования, а сама память выделяется при старте исполняемого файла.
Типы в C имеют фиксированный размер. Для глобальных и статических переменных компилятор определит необходимое количество выделяемой памяти и скомпилирует это требование внутрь приложения. Например, запись: static int number = 0 на машине с 64 битной macOS будет занимать 4 байта, потому что именно столько занимает тип int. А следующий массив static int numbers[10] = <0,1,2,3,4,5,6,7,8,9>; будет занимать sizeof(int) * 10 — т.е. 40 байт.
Автоматическое выделение памяти — когда требования к выделяемому месту в памяти для того или иного блока в программе (например, функции) — определяются, а после выделяется память непосредственно внутри стека вызовов, когда происходит вход в стековый кадр, а освобождение памяти происходит сразу после завершения работы кадра.
Тут использованы не тривиальные термины, поэтому немного в них углубимся. Стек вызовов — в теории вычислительных систем, LIFO-стек, хранящий информацию для возврата управления из подпрограмм (процедур, функций) в программу (или подпрограмму, при вложенных или рекурсивных вызовах) или для возврата в программу из обработчика прерывания (в т.ч. При переключении задач в многозадачной среде). При вызове подпрограммы в стек заносится адрес памяти следующей инструкции приостановленной программы, а управление передается подпрограмме. При возврате из подпрограммы адрес возврата снимается со стека и управление передается на следующую инструкцию приостановленной программы (или подпрограммы). Стековый кадр — механизм передачи аргументов и выделения временной памяти с использование системного стека. При вызове процедуры в стек сохраняется не только адрес возврата, как мы выяснили раньше, но и аргументы, с которыми вызывается процедура. Таким образом, процедура получает стек, на вершине которого лежит адрес возврата, а подним — аргументы, с которыми она была вызвана.
Чтобы лучше понять автоматическое выделение памяти рассмотрим небольшой программный код:
В данном коде используется как статическое, так и автоматическое выделение памяти: память для константы five_ninths выделяется статически, потому что присутствует ключевое слово static. Память для переменной c внутри функции celsius выделяется автоматически, при вызове функции celsius и сразу освобождается по ее завершению. Тоже самое происходит для переменной f внутри функции main. Память для хранения результата вызова функции celsius также выделяется автоматически. Вся выделенная автоматически память для работы нашей головной функции main освободится по её завершению.
Динамическое выделение памяти — когда память может быть запрошена и выделена динамически (т.е. во время работы программы) с помощью вызовов специальных API, ответственных за выделение памяти.
Есть случаи, когда ни статическое выделение памяти, ни автоматическое не подходят для решения задач. Такой случай — когда надо выделить память в процессе исполнения программы, т.е. в зависимости от тех или иных действий пользователя. В этом случае используются API вызовы для выделения памяти. Операционная система всегда резервирует секцию системной памяти для динамического выделения памяти. Данная секция называется heap (куча).
Конструкция системы управления памятью в Python
Будучи основанным на языке C, CPython вынужден использовать ограничения трех типов работы с памятью: статическое, автоматическое и динамическое выделение памяти. А особенности архитектуры самого Python еще более усложняют процесс работы с памятью:
- Python язык с динамической типизацией переменных. Размер требуемой памяти для переменной не может быть определен в процессе компилирования.
- Большинство базовых типов переменных в Python имеют динамический размер. Тип list может быть любого размера, dict может иметь любое количество ключей и т.п.
- Имена в Python могут быть переиспользованы для присваивания значений разных типов, например:
- a_value = 1 a_value = “string” a_value = [“some”, “list”, “here”]
Учитывая все вышеописанное, CPython вынужден максимально полагаться на динамическое выделение памяти, в связи с чем он также вынужден добавлять новые способы автоматизирования освобождения выделенной памяти с использованием сборщика мусора и алгоритмов, подсчитывающих количество ссылок на объект. Python не перекладывает на плечи разработчика заботы о ручном выделении памяти, вместо этого он занимается динамическим выделением памяти в автоматическом режиме используя единый и унифицированный API. Данный порядок вещей обязывает использовать этот API во всех местах в CPython, а также в других базовых модулях языка, написанных на C.
Типы выделения памяти
В CPython есть три типа выделения памяти:
- Raw — используется для выделения памяти из системной кучи (heap). Используется, когда необходимо выделить большой объем памяти, либо когда выделяется память не для стандартных Python объектов. Можно сказать, что данный механизм выделения памяти используется в двух случаях: либо, когда он вызван напрямую, либо когда запрос на выделение памяти превышает 512 KB. Механизм получает требуемое для выделения количество байт и вызывает функцию malloc(size).
- Object — используется для выделения памяти для всех объектов, относящихся к Python. Основной задачей механизма Объектного выделения памяти является выделение памяти непосредственно для Python объектов. Превосходным примером работы Object механизма — конструктор типа PyLongObject(int): при создании объекта типа PyLongObject память выделяется с помощью Object механизма, а объем памяти определяется как размер PyLongObj структуры, плюс количество памяти необходимое для хранения цифр. Тут следует уточнить, что Python long тип никак не соответствует типу long в C. Python хранит большие числа в виде списка цифр. Т.е. Число 1287654319 в Python на самом деле представлено как список цифр [1, 2, 8, 7, 6, 5, 4, 3, 1, 9].
- PyMem — выделение памяти с помощью API.
Что касается механизмов выделения памяти, то CPython использует два механизма:
- Механизм выделения памяти Операционной Системой (malloc) для Raw типа выделения памяти.
- Собственный встроенный механизм (pymalloc) — для PyMem и Object.
Встроенный механизм выделения памяти в CPython работает поверх механизма системного выделения памяти, имея свой собственный алгоритм для этого. Алгоритм очень похож на тот, что используется в механизме системного выделения, но со своими CPython-особенностями:
- Большинство запросов на выделение памяти — маленькие, фиксированных размеров.
- Максимальное количество памяти, которое может выделить pymalloc — 256 KB, все, что больше — отправляется на выделение системе.
- Pymalloc использует GIL, вместо системного механизма безопасности потоков.
Чтобы лучше представлять весь процесс, связанный с выделением памяти, рассмотрим аналогию — стадион. Этот стадион “ФК CPython”. Чтобы максимально эффективно управлять посетителями, “ФК Cpython” принял решение разбить стадионы на большие сектора (от A до E), а каждый сектор, в свою очередь, разбить на ряды с сидячими местами (по 40 рядов на сектор). Передние ряды — премиальные и занимают больше места, там могут уместиться до 80 человек в ряду. Сзади же находятся ряды бюджетные, способные вместить до 150 человек в ряде.
А теперь займемся сопоставлением аналогий:
- Стадион имеет сиденья, а pymalloc имеет блоки памяти.
- Стадион имеет разные типы рядов — бюджетные, обычные, премиальные, в которых установлены сиденья фиксированных размеров. Так же в pymalloc блоки памяти имеют свой фиксированный размер.
- Сиденья одного размера объединены в ряды на стадионе, а в pymalloc блоки памяти одного размера объединяются в пулы.
- Стадион четко знает, где находится какой сектор, какой ряд и какие места, аналогично в pymalloc центральный регистр обладает сведениями о том, где какой блок хранится, а также о количество блоков в пуле.
- Когда на стадионе заполняется один ряд, люди используют ряды с другого ряда. В pymalloc, после заполнения пула блоков, начинает использоваться новый пул с блоками.
- Ряды стадиона объединяются в секции. В pymalloc пулы объединяются в арены.
Из вышеописанного понятно, что наибольшей группой памяти является арена. CPython создает арены размером с 256KB, чтобы совпадать с размерами в системных страницах. Арены выделяются внутри системной кучи (heap). Ниже можно увидеть выделенные четыре арены в heap-е.
Арене соответствует объект arenaobject со следующей структурой:
- address — адрес в памяти арены;
- pool_address — адрес следующего пула для выделения памяти;
- nfreepols — количество доступных пулов в арене;
- ntotalpools — общее количество пулов в арене;
- freepols — однонаправленный список доступных пулов;
- nextarena — следующая арена (арены связаны друг с другом, потому что находятся в двунаправленном списке арен);
- prevarena — предыдущая арена.
Внутри арены, как уже известно, находятся пулы, а пулы состоят из блоков размером до 512 байт. Для 32 битных систем размер выделяемого блока памяти увеличивается с шагом 8 байт, а для 64 битный — с шагом 16 байт. Т.е. для 32 битных систем, если приложение просит выделить от 1 до 8 байт, то ему будет выделен блок размером 8 байт; если надо выделить от 9–16, то блок размером 16 байт выделится; если от 17–24 байт надо — 24 байтовый блок и так далее. Для 64 битных тоже самое, только начиная с 16 байт блоки: 16, 32, 48 и т.д. Размер самого пула статичен и равен 4 KB, таким образом внутри арены всегда ровно 64 пула. Пулы имеют три состояния: полные (когда все доступные блоки памяти внутри пула уже выделены); используемые (пул уже выделен в использование и некоторые блоки памяти внутри него — заняты); пустые (пул выделен в использование, но внутри ни один блок не занят). Каждому пулу, по аналогии с ареной, соответствует объект типа poolp, который представляет собой структуру pool_header со следующими свойствами:
- ref — число выделенных блоков памяти в пуле;
- freeblock — указатель на первый свободный блок в пуле;
- nextpool — указатель на следующий пул;
- prevpool — указатель на предыдущий пул;
- arenaindex — индекс арены;
- szidx — индекс класса текущего пула
- nexttoffset — количество байт от начала пула до первого свободного блока памяти;
- maxnextoffset — максимально допустимое значение nexttoffset.
Каждый пул всегда хранят в себе двунаправленный список к следующим и предыдущим пулам соответствующего размера класса, поэтому при выделении памяти, благодаря этому списку, пулы легко можно перебирать в поиске нужного. Что касается блоков внутри пула, то они работают по следующим правилам:
- Внутри пула блоки фиксированных размеров могут быть выделены и освобождены.
- Доступные для использования блоки перечислены в специальном однонаправленном списке freeblock.
- Когда блок освобождается, он добавляется в самое начало списка freeblock.
- Когда пул инициализируется, только первые два блока пула находятся в списке freeblock.
- Пока статус у пула “используемый” — внутри него есть хотя бы один свободный для выделения блок памяти.
Механизмы освобождения памяти
Ссылки на объект
Как обсуждалось выше, CPython использует механизм динамического выделения памяти языка C. Требования к выделяемой памяти определяются непосредственно в процессе работы приложения, а память выделяется в системе с использованием API (PyMem).
Чтобы добиться простого и прозрачного управления памятью, Python внедрил две стратегии работы с выделенной памятью: счетчик ссылок на объект и сборщик мусора. О первой стратегии мы сейчас и поговорим.
Чтобы создать переменную в Python необходимо присвоить значение переменной с уникальным именем. Например:
Когда происходит операция присваивания значения переменной, Python проверяет на факт использования имени как в локальной, так и в глобальной области видимости. Если переменной нет ни в locals(), ни в globals() словарях — то будет создан новый объект, в нашем случае — список, указатель будет сохранен в locals() словаре. И с этого момента счетчик ссылок для переменной my_var равняется единице. Пока значение счетчика ссылок больше нуля, память, выделенная для переменной не может быть освобождена.
Если мы обратимся к C-исходникам CPython, то увидим, что существуют два метода, которые используются для увеличения и уменьшения значения счетчика ссылок: Py_INCREF() и Py_DECREF() соответственно.
Как только счетчик ссылок становится равным нулю, Python считает, что переменная более не нужна и тут же освобождает память, выделенную ранее под её нужды.
Если задаться вопросом, где же хранится счетчик ссылок, то ответ прост: любой объект типа PyObject имеет свойство ob_refcnt, в нем и хранится количество ссылок на объект. Данный счетчик может быть увеличен во множестве случаев. Если, опять же, посмотреть на код CPython, то в нем можно найти свыше 3000 вызовов метода Py_INCREF(). Наибольшее количество вызовов происходит в следующих случаях:
- произошло присваивание значения имени переменной;
- произошла использование объекта в качестве аргумента функции или метода;
- объект был возвращен или был произведен yield из функции.
Что касается уменьшения счетчика ссылок, то оно происходит, когда происходит выход за пределы области в которой была использована переменная. К таким областям относятся: функции, методы, comprehension-ы или лямбды.
Логика Py_DECREF() немного сложнее логики Py_INCREF(). Если вторая просто увеличивает счетчик на единицу, то внутри Py_DECREF() происходит еще обработка случая, когда счетчик дошел до нуля, а значит необходимо освободить память. Если внутри Py_DECREF выполняется условие, что счетчик равен нулю, то тут же происходит вызов деструктора объекта с помощью метода _Py_Dealloc(obj), что приводит к освобождению памяти.
За очевидной, казалось бы, простотой операций увеличения и уменьшения счетчика, стоят довольно сложные реализации, которые благодаря Python-у скрыты от разработчикам, нам не надо заботиться о работе механизмов под капотом. Но все же необходимо их понимать. Например, рассмотрим следующий код:
После первой строки счетчик объекта a будет равен 1. После второй счетчик объекта b будет равен так же 1. Правое выражение третьей строки будет разбито аж на три операции:
- Вызов метода LOAD_FAST(), который загружает объект по его имени и кладет его в начало стека значений. Данный вызов будет произведен для переменной a, значение будет запущено в стек, а счетчик ссылок увеличен на единицу.
- Произойдет аналогичный вызов метода LOAD_FAST() для переменной b с последующим увеличением счетчика b на единицу.
- Наконец, будет выполнена операция умножения, которая знает, что значения переменных слева и справа уже прошли через метод LOAD_FAST и их значения можно взять из стека значений, а также операция знает, что значения счетчиков были увеличены на единицу, поэтому, закончив свою работу вычислительную, операция умножения произведет уменьшение счетчиков ссылок для обоих переменных.
Наконец, еще одно любопытное наблюдение. Напишем простой код:
Мы присвоили список пустой переменной y и хотим узнать, что хранится в счетчике ссылок объекта теперь. Нам выведется число 2. Дело в том, что счетчик увеличился во второй строке, при непосредственном присваивании, а также увеличился в третьей строке, т.к. наш объект был передан в качестве аргумента в метод getrefcount.
А вот еще более интересный пример:
В результате выполнения операции мы получим огромное число, по типу 135 или 170. Почему так? Потому что в нашем случае тип int — не изменяемый. CPython уже в каком-то месте своего кода использует единичку в качестве значения переменной. Нет смысла создавать новые объекты, достаточно просто использовать ссылку на когда-то созданную единичку, что CPython и делает. Поэтому счетчик такой большой, он учитывает как наше обращение к единичке, так и все обращения к единичке из библиотек Python. Если же мы напишем более редкое число, например 397834, то значение счетчика разительно уменьшится, скорее всего мы увидим 4 или около того.
В общем, система с подсчетом количества ссылок и последующим освобождением памяти — прекрасна. За исключением одного неприятного момента — циклические ссылки.
Счетчик для x будет по-прежнему равен 1, потому что x ссылается сам на себя во второй строчке кода. Тут-то на помощь приходит вторая стратегия управления выделенной памятью — сборщик мусора.
Сборщик мусора
Как часто к вам приезжают сборщики мусора после того как вы его выкинули? Раз в день или раз в неделю? У каждого свой график, но факт остается для всех одним: после того как мы что-то выбросили, нам необходимо подождать какое-то время, чтобы приехали сборщики мусора и забрали его у нас.
CPython руководствуется аналогичными принципами. По умолчанию сборщик мусора всегда активен и работает в фоновом режиме. Поскольку алгоритм работы сборщика мусора намного сложнее алгоритма работы со счетчиком ссылок, сборка мусора не происходит непрерывно. В противном случае, на данный процесс бы тратилась львиная доля мощности CPU. Поэтому сборщик мусора выполняет свою работу периодически, после определенного количества операций.
Чтобы понять как работает сборщик мусора необходимо понять два основных концепта. Первый из них — так называемые поколения (генерации). Сборщик следит за всеми объектами в памяти. Новый объект начинает свою жизнь в первом поколении сборщика мусора. Если после того как была проведена инициация работы сборщика Python-ом объект остается жив, он переводится во второе поколение (более старшее). Всего сборщик оперирует тремя поколениями, а объекты перебираются из одного поколения в другое каждый раз как переживают процесс работы сборщика над поколениям в котором они находятся в текущий момент времени.
Вторым концептом важным для понимания работы сборщика является значение пороговой величины количества объектов. Для каждого поколения сборщик имеет пороговое значение количества объектов в нем. Если количество объектов поколения превышает пороговое значение, то сборщик автоматически запускает процесс сборки мусора. Аналогично, выжившие объекты после сборки перейдут в новое поколение.
Основной целью сборщика мусора — найти и выявить более недоступные объекты (как например в примере выше, вследствие циклических ссылок) и пометить их как “мусор”. CPython алгоритм работы сборщика основывается на все тех же счетчиках ссылок. Просто говоря, сборщик, основываясь на значениях счетчика объектов проходит их и пытается найти циклы.
Сборщик работает с огромным количеством типов объектов, но не со всеми. Он берет в работу только те объекты, у которых стоит флаг Py_TPFLAGS_HAVE_GC. Следующие объекты поддерживают работу со сборщиком мусора:
- Классы, методы и функции
- Byte array, Byte и юникод строки
- Словари
- Enumeration объекты
- Исключения
- Списки, кортежи, множества
- Объекты памяти
- Модули и namespace-ы
- Итераторы и генераторы
Что касается объектов, которые не поддерживают работу со сборщиком, то это: Floats, Integers, Booleans и NoneType.
Кроме флага, описанного выше, есть еще такое понятие как не отслеживаемые сборщиком объекты. Предположим, у нас есть кортеж. Кортеж — неизменяемый. Казалось, его не надо отслеживать сборщику, но не все так просто. Кортеж может в себе хранить изменяемые объекты, такие как списки или словари, поэтому сборщик работает с кортежами. При создании кортежа, если он не пустой, он помечается как объект для отслеживания сборщиком. При запуске работы сборщика каждый кортеж начинает анализировать свое содержимое, чтобы понять, что там хранится? Если внутри кортежа хранятся только неизменяемые объекты (например, integers или booleans), кортеж исключит сам себя из списка отслеживаемых сборщиком, вызвав метод _PyObject_GC_UNTRACK(). Таким образом, более сборщик не будет работать с такими неотслеживаемыми объектами. В свою очередь, при создании пустого словаря, он автоматически сразу помечается как не отслеживаемый. Но, при добавлении отслеживаемого объекта в словарь, он тут же меняет свой статус на “отслеживаемый” сборщиком.
В отличие от счетчика ссылок, разработчик может влиять на работу сборщика:
- можно изменить пороговое значение объектов в поколении;
- можно вручную запускать процесс работы сборщика;
- можно вообще отключить работу сборщика целиком.
Для работы со сборщиком существует модуль gc. Рассмотрим пример:
Тут мы импортируем модуль и вызываем метод get_threshold. Как видим нам вернется кортеж из трех значений с пороговыми величинами для каждого из поколений сборщика. По умолчанию, самое молодое поколение имеет порог объектов равный 700, а два остальных поколения имеют предел в 10 объектов. Мы вольны изменить эти значения используя соответствующий метод: gc.set_threshold(1000, 15, 15).
Кроме этого, мы можем вызвать метод gc.get_count(), получив в результате аналогичный кортеж из трех значений, каждое из которых будет означать текущее количество объектов в поколении. Причем, что интересно, значения не будут пустыми даже, если вы еще не запустили приложение, потому что Python создает ряд объектов по умолчанию и, как следствие, они появляются в поколениях сборщика.
Если стоит задача запустить процесс работы сборщика вручную, то и это мы можем сделать используя метод gc.collect().
В заключении следует поднять такой вопрос: “Что делать со всеми этими знаниями о сборщике и возможностями на него повлиять?”. Краткий ответ — ничего. В общем и целом, разработчик не должен особо думать о сборщике мусора. Одной из ключевых особенностей (и главных плюсов) языка Python заключается в том, что он, будучи высокоуровневым языком программирования, снимает с разработчика обязанности думать о всяких нюансах работы “под капотом”, позволяя ему сфокусироваться на разработке бизнес-логики и архитектуры.
Ручное управление сборщиком ранее было оправдано, если проект упирался в какие-то физические лимиты, связанные с железом (памятью) на котором развернут проект. Но в настоящее время, во времена облаков и мощных хостингов, если вы ощущаете, что производительность страдает по вине сборщика, целесообразнее и дешевле просто увеличить мощность сервера.
Существует также мнение, что для улучшение производительности сборщик можно вообще отключить. Это и так, и не так. Так это для очень специфичных и крупных проектов. Например, многим известный кейс, описанный разработчикам Instagram, связанный с отключением сборщика. Instagram использует Django для своих web приложений. Разработчики запускают множество экземпляров своего приложения на одной машине. Эти экземпляры запускаются используя механизм master-child в котором child процессы имеют общую память с master. Разработчики Instagram заметили, что выделяемая память уменьшается невероятно быстро сразу после спауна child процессов. Начав углубляться в проблематику они выяснили, что всему виной сборщик мусора Python. Отключив его, они решили проблему с утекающей памятью и добились почти 10% прироста производительности приложения. Но это Instagram — проект с сотнями миллионов посетителей, где просто жизненно необходимо оптимизировать проект от и до для адекватной его работы. В подавляющем же большинстве проектов такие кастомизации избыточны, а штатная работа сборщика не представляет каких-либо проблем или существенных потерь производительности.
Узнаем точное кол-во памяти для объекта
В финальном разделе нашей статьи я хотел бы рассказать о том как можно узнать сколько памяти занимает тот или иной объект в Python. Эта, казалось бы, тривиальная задача имеет множество подводных камней, а ее изучение поможет лучше осознать как Python хранит данные.
Итак, как узнать сколько памяти занимает объект? Это очевидно, скажете вы. Надо импортировать пекедж sys и использовать встроенный метод getsizeof. Вполне понятно. Давайте проверим насколько это так?
В примере выше мы рассмотрели сколько занимает память целого числа, строки и списка. Вроде, все замечательно и вопросов не возникает. Но давайте рассмотрим еще немного примеров:
Тут результаты уже не столь очевидны, не правда ли? Пустая строка занимает 49 байт! Строка с одним символом занимает 50 байт, а строка с двумя — 51 байт, с тремя — 52. Т.е. каждый дополнительный символ увеличивает всего на 1 байт размер объекта. На самом деле, это поведение можно объяснить довольно легко. Как мы знаем, все в Python — объекты. Кроме самого значения объекта, как мы тоже уже знаем, объект хранит довольно много различной информации, поэтому, создав объект строковой и пустой — он сразу занял 49 байт. Ну а каждый последующий символ в строке действительно занимает по 1 байту. Давайте посмотрим, работает ли все это для других типов, например, для списка?
Да, как видим, в списках используется аналогичная логика. Пустой список занимает 64 байта, каждый новый элемент списка добавляет ему по 8 байт сверху. Вроде, метод срабатывает безупречно. Зачем это все было тестировать? Для того, чтобы показать следующий пример:
Как говорится, как тебе такое, Илон Маск? Сначала все идет хорошо, список из двух элементов занимает 80 байт. Из четырех на 16 байт больше (по 8 байт на элемент) — 96. А вот дальше сюрприз: размер списка с вложенным списком с общим количество элементов равным шести — всего 88 байт, т.е. всего на 8 байт больше, чем размер списка из двух элементов! Вот мы и дошли до момента показывающего, что getsizeof работает некорректно, а все, что произошло выше можно вполне логически объяснить.
Что происходит, когда создается пустой список? Объект списка с его базовыми свойствами и информацией сохраняется в память и занимает 64 байта. Каждый элемент списка, независимо от его типа, занимает дополнительные 8 байт, потому что в списке не хранятся сами данные элемента, в элементе списке хранится просто указатель на объект. Итого, наш сложный вложенный список [1, 2, [3, 4, 5, 1]] для Python выглядит вот так: [ссылка на объект1, ссылка на объект2, ссылка на объект3]. Т.е. всего три элемента-указатель на объекты каждый размером по 8 байт — итого: 88 байт.
Но как найти настоящий физический объем памяти, занимаемый списком? Первым делом нам надо использовать метод сборщика мусора gc.get_references(), который вернет все объекты, привязанные к нашему текущему объекту. После мы в цикле проходимся по всем объектам, что нам вернутся и суммируем память, которую они занимают. В итоге, мы получим размер нашего списка — 324 байта. Да, это очень много для хранения шести цифр, поэтому, если объемы хранимых данных велики, а данные представляют собой числа, лучше использовать, например, numpy массивы. Но это уже выходит за рамки нашей статьи.
На этом все. В данной статье мы рассмотрели довольно сложную тему работы с памятью под капотом языка Python, основные типы и механизмы выделения памяти, а также стратегии работы с выделенной памятью.
Данная статья основывается на блестящей книге Anthony Shaw — “CPython Internals”.
Советы по оптимизации памяти в Python
Управление памятью в Python — задача не из простых, она требует хорошего понимания объектов и структур данных Python. В отличие от C / C ++, пользователи не могут контролировать управление памятью. Его берет на себя сам Python. Однако, имея некоторое представление о том, как работает Python и о модулях поддержки памяти, мы можем каким-то образом пролить свет на то, как контролировать эту проблему.
Сколько памяти выделяется?
Есть несколько способов получить размер объекта в Python. Вы можете использовать sys.getsizeof() , чтобы получить точный размер объекта, objgraph.show_refs() , чтобы визуализировать структуру объекта, или psutil.Process().memory_info().rss , чтобы получить всю память, выделенную в данный момент.

Еще один вариант — tracemalloc. Он включен в стандартную библиотеку Python и обеспечивает трассировку выделения памяти на уровне блоков, статистику общего поведения памяти программы.
Наиболее часто используемый файл — это объект arr, который занимает 2 блока памяти общим размером 2637 МБ. Остальные объекты минимальны.
Другой важный метод — оценить, сколько памяти требуется для запуска процесса. Об этом можно догадаться, отслеживая пиковое использование памяти процессом. Чтобы измерить пиковую память, вы можете использовать приведенный ниже код в конце процесса.
Имея пиковое число и объем данных, помещенных в процесс, вы можете каким-то образом оценить объем памяти, который будет потреблен для вашего следующего процесса.
1. Используйте Pytorch DataLoader
Обучение большого набора данных является узким местом для вашей памяти, и вы никогда не сможете обучить полную модель, поскольку весь набор данных никогда не умещается в вашей памяти одновременно, особенно для неструктурированных данных, таких как изображение, текст, голос, . Однако, с Pytorch DataLoader вам удастся настроить различные мини-пакеты для всего набора данных, и каждый из них будет непрерывно загружаться в вашу модель (количество образцов зависит от объема вашей памяти). Вы можете увидеть здесь руководство по использованию Pytorch DataLoader.
Однако, если вы хотите обучить модель машинного обучения на табличных данных без использования глубокого обучения (следовательно, без использования Pytorch) или у вас нет доступа к базе данных и вам нужно работать исключительно с памятью, что будет выбором для оптимизация памяти?
2. Оптимизированный тип данных
Понимание того, как данные хранятся и обрабатываются, а также использование оптимального типа данных для задач, сэкономит вам огромное пространство в памяти и время вычислений. В Numpy существует несколько типов, включая логическое (bool), целое число (int), целое число без знака (uint), float, сложное, datetime64, timedelta64, object_ и т. Д.
Я сужаю их до uint, int и float, поскольку они наиболее распространены при обучении моделей, обрабатывающих данные в Python. В зависимости от различных потребностей и целей использование достаточного количества типов данных становится жизненно важным ноу-хау. Чтобы проверить минимальные и максимальные значения типа, вы можете использовать функцию numpy.iinfo() и numpy.finfo() для числа с плавающей запятой.
Ниже приводится сводная информация по каждому типу.

Размер файла CSV удваивается, если тип данных преобразован в numpy.float64, который является типом по умолчанию для numpy.array, по сравнению с numpy.float32. Следовательно, float32 — один из оптимальных для использования (тип данных Pytorch также float32).
Поскольку тип данных по умолчанию numpy.float() — float64, а numpy.int() — int64, не забудьте определить dtype при создании массива numpy, чтобы сэкономить огромное количество места в памяти.
При работе с DataFrame будет еще один обычный тип — «объект». Преобразование из объекта в категорию для функции, имеющей различные повторы, ускорит время вычислений.
Ниже приведен пример функции для оптимизации типа данных pd.DataFrame для скаляров и строк.
Еще один способ легко и эффективно уменьшить pd.DataFrame объем памяти — это импортировать данные с определенными столбцами с использованием usercols параметров в pd.read_csv()
3. Избегайте использования глобальных переменных, вместо этого используйте локальные объекты
Python быстрее извлекает локальную переменную, чем глобальную. Более того, объявление слишком большого количества переменных как глобальных приводит к проблеме нехватки памяти, поскольку они остаются в памяти до завершения выполнения программы, а локальные переменные удаляются, как только функция завершается, и освобождает пространство памяти, которое они занимают. Подробнее читайте в разделе Набор реальных навыков, которым должны овладеть специалисты по данным.
4. Используйте ключевое слово yield
Python yield возвращает объект-генератор, который преобразует данное выражение в функцию-генератор. Чтобы получить значения объекта, его необходимо повторить, чтобы прочитать значения, заданные для yield. Чтобы прочитать значения генератора, вы можете использовать list (), for loop или next ().
Однако генераторы предназначены для одноразового использования. Если вы обращаетесь к нему во второй раз, он возвращается пустым.
Поскольку значение не возвращается, если объект-генератор не повторяется, память не используется, когда функция Yield определена, в то время как вызов Return в функции приводит к распределению в памяти.
Следовательно, Yield подходит для больших наборов данных или когда вам не нужно хранить все выходные значения, а только одно значение для каждой итерации основной функции.
Глядя на приведенный выше код, понимание списка в 6441 раз тяжелее, чем генератор, и работает медленнее, чем другой генератор.
5. Встроенные методы оптимизации Python
Используйте встроенные функции Python для повышения производительности кода, список функций.
Используйте __slots__ при определении класса
Атрибуты объектов класса Python хранятся в виде словаря. Таким образом, определение тысяч объектов аналогично размещению тысяч словарей в области памяти. И добавление __slots__ (что снижает потери пространства и ускоряет программу, выделяя место для фиксированного количества атрибутов.)


Что касается использования памяти, учитывая, что в объекте класса больше нет __dict__, объем памяти заметно уменьшается с (64 + 16 + 120) = 200 до 56 байт.
Используйте join () вместо «+» для объединения строки
Поскольку строки неизменяемы, каждый раз, когда вы добавляете элемент к строке с помощью оператора «+», новая строка будет выделяться в пространстве памяти. Чем длиннее строка, тем больше потребляется памяти, тем менее эффективным становится код. Использование join() может повысить скорость ›на 30% по сравнению с оператором« + ».
Есть и другие способы повысить скорость и сэкономить память, подробности см. здесь.
itertools
Или сгладьте список с помощью itertools.chain ()
Ознакомьтесь с документацией itertools, чтобы узнать о других методах. Рекомендую изучить:
- itertools.accumulate (iterable, func): накапливать через итерацию. func может быть operator.func или функциями Python по умолчанию, такими как max, min…
- itertools.compress (iterable, selectors): фильтрует итерацию с другим (другой объект можно рассматривать как условие)
- itertools.filterfalse (предикат, итерируемый): отфильтровать и отбросить значения, удовлетворяющие предикату. Это полезно и быстро для фильтрации объекта списка.
- itertools.repeat (object [, times]): повторить значение объекта N раз. Однако я предпочитаю использовать умножение списка [‘hi’]*1000 , чтобы повторять «привет» 1000 раз, чем использовать itertools.repeat(‘hi’, 1000) (12,2 мкс на цикл против 162 мкс на цикл соответственно)
- itertools.zip_longest (* iterables, fillvalue = None): заархивируйте несколько итераций в кортежи и заполните значение None значением, указанным в fillvalue .
6. Накладные расходы на импорт выписки
Оператор import может быть выполнен из любого места. Однако выполнение вне функции будет выполняться намного быстрее, чем внутренняя, даже если пакет объявлен как глобальная переменная (doit2), но, в свою очередь, занимает больше места в памяти, чем другой.

7. Фрагмент данных
Я с уверенностью могу сказать, что в большинстве случаев вы не используете все свои данные сразу, а загрузка их одним большим пакетом может привести к сбою памяти. Следовательно, разделение данных или загрузка небольшими порциями, обработка порции данных и сохранение результата — один из наиболее полезных методов предотвращения ошибок памяти.
pandas позволяет сделать это с помощью параметров chunksize или iterator в pandas.read_csv() и pandas.read_sql() . sklearn также поддерживает обучение небольшими порциями с помощью partial_fit() method для большинства моделей.
На вынос
Обработка ошибки памяти в Python — сложная задача, и основная причина может быть никогда не обнаружена, если вы пойдете другим путем.