Распределение памяти в Python: сколько и в каких случаях занимают типы данных
Как устроено выделение памяти под объекты в Python, как работает очистка памяти и в чём разница в памяти на примере типов list, dict и tuple.
21К открытий26К показов
Идея статьи возникла после просмотра одного видео, где автор разбирает различные способы создания списка из одинаковых элементов. Меня заинтересовала эта тема, и я начал углубляться в нее. В частности, почему в том или ином случае объем занимаемой памяти отличается.
В этом материале разберем, как устроено выделение памяти под объекты в 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
Начнем сравнения с 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 байт на каждый элемент.
С созданием кортежа из списка или другой коллекции тоже нет таких неожиданностей, как со списком. Т.к. не надо закладывать место под изменение количества элементов.
Dict
Словарь представляет из себя массив ключей и массив значений, где каждый ключ связан с одним значением. На ключ накладывается ограничение по уникальности в пределах словаря. Поэтому ключами могут быть объекты только неизменяемых типов. Значением же может быть объект любого типа.
Как и списки, словари хранятся в виде двух объектов. Первый, содержит информацию о самом словаре и всегда остается в одном и том же блоке. Второй, хранит пары ключ-значение и может перемещаться между блоками при изменении размера. Но при этом пустой словарь занимает гораздо больше места.
Как видно в примере, словарь занимает 240 байт. При создании словаря выделяется место под несколько элементов, а не только после добавления элемента, как это было со списком.
Если вызвать метод clear, очищаются не только все элементы. Изначально зарезервированная память тоже будет освобождена.
В итоге словарь стал занимать всего 72 байта. Гораздо меньше, чем при создании.
Как и со списком, на каждую новую пару ключ-значение весь объект не перемещается в новый блок. Чтобы избежать частых затрат на перемещение, новый блок берется с запасом на несколько элементов.
Заключение
Знать, какой объем памяти будет занимать тот или иной объект, полезно. Например, в условиях проекта или задачи указаны жесткие ограничения по памяти. Или кусок кода вызывается очень часто, необходимо понять, не будет ли выделение/освобождение памяти являться узким местом вашей системы.
Поэтому рекомендую, прикидывать примерные затраты памяти, еще на момент написания кода. И никто не отменяет переиспользование уже существующих объектов.
Если вы заинтересовались этой темой и решили углубиться в нее, то обратите внимание на следующие материалы:
— Memory management in Python. Тут подробно описан механизм работы менеджеров памяти и организации блоков.
— Memory Management. Не забываем про официальную документацию.
— Garbage Collection for Python. Здесь детальнее рассказывают про алгоритм работы сборщика мусора.
— The Garbage Collector. И еще один материал о сборщике мусора.
21К открытий26К показов