Время прочтения: 5 мин.

У Python есть множество достоинств, но есть несколько недостатков, которые мешают Python стать действительно вездесущим языков. Один из таких недостатков — это большой расход памяти. Если вы работаете на современном компьютере и не пишите приложения схожие с GTA 5, то возможно вы никогда и не задумывались, сколько памяти расходует ваша программа. Даже если вы data engineer, и вам приходится работать с большим количеством данных, возможно, вы встречались с memory error. В дальнейшим мы рассмотрим сколько расходуют памяти примитивные объекты в python. А прежде чем лезть в дебри, укажем момент работы с памятью в Python, который должен знать даже программист, который никогда не работает с крупными проектами или большими данными.

Работа в Python с памятью происходит достаточно просто и понятно, используя систему счетчиков ссылок, и память, занимавшую этим объектом, освобождается, когда счетчик равен нулю.

Рассмотрим размер основных примитивных типов данных: integer(int), float, tuple, string(str), list, dict.

Основные объекты

Каков размер самого простого и часто используемого объекта int? Если вы перешли на Python с какого-либо С подобного языка, то ответите, что не более 8 байт. Сравним с python:

*Я использую Python версии 3.7.0 64x.

>>> sys.getsizeof(None)
16
>>> sys.getsizeof(3)
28
>>> sys.getsizeof(9223372036854775808)
36
>>> sys.getsizeof(102946385896929697683768295837598259692)
44
>>>sys.getsizeof(98765432134567890987654321234567890987654321234567898765432123456787654)
56

В 64 битном С,  int занимает 4 байта, а это гораздо меньше, чем в Python.

Числа с плавающей запятой в Python:

>>> sys.getsizeof(3.14159265359)
24

А что насчёт строковых значений?

>>> sys.getsizeof("")
49
>>> sys.getsizeof("hello world!")
61

Пустая строка в Python в 64 битной среде, занимает 49 байт!!!

Затем потребление памяти увеличивается за счет увеличения полезного размера значения.

Рассмотрим еще несколько не менее важных объектов:

Размер списка:

>>> sys.getsizeof([])
64
>>> sys.getsizeof([1, "she", True])
88

В 64-битном С размер пустого std::list() равен 16 байт и это в 4 раза меньше, чем в Python.

Рассмотрим словарь:

>>> sys.getsizeof({})
240
>>> sys.getsizeof({"cat":123, "dog":321})
240
>>> sys.getsizeof({"cat":123, "dog":321, "parrot":456, "pig":765, "bird":876, "fox":295})
368

Например, пустой словарь в С занимает 48 байт.

Ну что? Мы разобрали занимаемый объем памяти самых популярных объектов в Python. Но, наверное, никто из читателей после этого сравнения не перейдет на С, я тоже не перейду. Для того, чтобы качественно писать код, с точки зрения оптимизации, мы должны иметь представление о требованиях создаваемых нами объектов. Посмотрим, как происходит выделение памяти под капотом…

Внутреннее управление памятью

Я упоминал выше, что когда счетчик ссылок обнуляется, память освобождается, звучит это просто, понятно и логично, но здесь есть несколько подводных камней.

Обратимся к ИСТОРИИ…

Возьмём любую версию python, ниже 2.1.

Если заглянуть под капот, то мы обнаружим, что если ОС выделила Python программе память, то эта память никогда не вернется в ОС. Интерпретатор Python оставляет эту память себе, до следующего использования, это ускоряет работу программы, так как ОС не требуется постоянное выделение памяти для процесса. Но если в программе есть место, где потребление памяти резко возрастает на небольшой промежуток времени и дальше программа требует значительно меньше памяти, то Python всю память, которая требуется для этого пика будет сохранять за собой и не отдаст ее ОС, что приводит к уменьшению производительности системы в целом.

Распределитель памяти Python, называемый pymalloc, был написан Владимиром Марангозова и изначально был экспериментальной функцией Python 2.1 и 2.2, прежде чем стать включенной по умолчанию в 2.3.

В python программах используется много различных мелких объектов, которые то создаются, то уничтожаются, для этого вызываются функции malloc()(для выделения) и free()(для освобождения). Я уже упоминал, что выделение памяти ОС несколько затяжной процесс, поэтому pymalloc выделяет память куском в 256 Кб и называется это арена. Сама арена делится на пулы размер каждого 4Кб, и пулы уже делятся на блоки фиксированного размера под наши объекты.

Если мы создаем объект, распределитель проверяет, есть ли пулы, с блоками нужного нам размера. Связанный массив usedpoolsхранит пулы каждого размера. И каждый пул имеет связанный список доступных блоков. Если есть нужный нам блок, то мы берем его из списка пула, эта операция очень быстрая.

Если нет пулов, разбитых на нужные нам блоки, то нужно найти свободный пул, свободные пулы хранятся в связанном списке freepools. Если в списке есть пул, мы берем его, если нет, то придется выделить новый пул, если в Арене еще есть место, то мы отрежем новый пул используя arenabase. Если в Арене нет места, придется выделить новую Арену, вызвав malloc(). Теперь, когда у нас есть нужные блоки, забираем его с помощью usedpools.

Когда программа удаляет объект, освобождается блок. Блок помещается в свободный список пула и если пул был полностью выделен, то он добавляется к usedpools (список пулов, имеющих свободные блоки), а если пул теперь полностью свободен, то он добавляется к freepools.

Вывод:

В данной статье мы рассмотрели, как устроено распределение памяти в Python. Как вы могли заметить, Python очень прожорливый на память язык. В следующей статье мы расскажем, как обновление распределителя повлияло на объем потребляемой Python памяти.