что такое модель памяти python
🐍 Помнить всё. Как работает память в Python
Leo Matyushkin
Python многое делает за нас. Мы привыкли не заботиться об управлении памятью и о написании соответствующего кода. Пусть эти процессы и скрыты, но без их понимания трудно подготовить производительный код для высоконагруженных задач. В этой статье мы рассмотрим модель памяти Python и то, как интерпретатор Python взаимодействует с оперативной памятью компьютера.
Диспетчер памяти: «командовать парадом буду я»
Диспетчер памяти — своеобразный портье, который регистрирует и расселяет гостей отеля. Каждый постоялец получает ключ с номером комнаты, так что ни один из гостей не может заселиться не в свой номер. Две программы не могут одновременно записать переменную в одно место виртуальной памяти.
Фактически за это отвечает даже не диспетчер задач, который ожидает гостей за регистрационной стойкой, а GIL — глобальная блокировка интерпретатора. GIL гарантирует: в один и тот же момент времени байт-код выполняется только одним потоком. Главное преимущество — безопасная работа с памятью, а основной недостаток в том, что многопоточное выполнение программ Python требует специфических решений.
Очевидно, программа не сама выполняет сохранение и освобождение памяти — ведь мы не пишем соответствующих инструкций. Интерпретатор лишь запрашивает диспетчер памяти сделать это. А диспетчер уже делегирует работу, связанную с хранением данных, аллокаторам — распределителям памяти.
Организация доступной виртуальной памяти
Непосредственно с оперативной памятью взаимодействует распределитель сырой памяти (raw memory allocator). Поверх него работают аллокаторы, реализующие стратегии управления памятью, специфичные для отдельных типов объектов. Объекты разных типов — например, числа и строки — занимают разный объем, к ним применяются разные механизмы хранения и освобождения памяти. Аллокаторы стараются не занимать лишнюю память до тех пор, пока она не станет совершенно необходимой — этот момент определен стратегией распределения памяти CPython.
Python использует динамическую стратегию, то есть распределение памяти выполняется во время выполнения программы. Виртуальная память Python представляет иерархическую структуру, оптимизированную под объекты Python размером менее 256 Кб:
Блок содержит не более одного объекта Python и находится в одном из трех состояний:
Арена
Информацию о текущем распределении памяти в аренах, пулах и блоках можно посмотреть, запустив функцию sys._debugmallocstats() :
Чтобы не произошло утечки памяти, диспетчер памяти должен отследить, что вся выделенная память освободится после завершения работы программы. То есть при завершении программы CPython дает задание очистить все арены.
Освобождение памяти: счетчик ссылок, сборщик мусора
Для освобождения памяти используются два механизма: счетчик ссылок и сборщик мусора.
Однако счетчик ссылок неспособен отследить ситуации с циклическими ссылками. К примеру, возможна ситуация, когда два объекта ссылаются друг на друга, но оба уже не используются программой. Для борьбы с такими зависимостями используется сборщик мусора ( garbage collector ).
Заключение
Сохранение и освобождение блоков памяти требует времени и вычислительных ресурсов. Чем меньше блоков задействовано, тем выше скорость работы программы. Позволим себе дать несколько советов, касающихся экономной работы с памятью:
На Python создают прикладные приложения, пишут тесты и бэкенд веб-приложений, автоматизируют задачи в системном администрировании, его используют в нейронных сетях и анализе больших данных. Язык можно изучить самостоятельно, но на это придется потратить немало времени. Если вы хотите быстро понять основы программирования на Python, обратите внимание на онлайн-курс «Библиотеки программиста». За 30 уроков (15 теоретических и 15 практических занятий) под руководством практикующих экспертов вы не только изучите основы синтаксиса, но и освоите две интегрированные среды разработки (PyCharm и Jupyter Notebook), работу со словарями, парсинг веб-страниц, создание ботов для Telegram и Instagram, тестирование кода и даже анализ данных. Чтобы процесс обучения стал более интересным и комфортным, студенты получат от нас обратную связь. Кураторы и преподаватели курса ответят на все вопросы по теме лекций и практических занятий.
Использование памяти в Python
Сколько памяти занимает 1 миллион целых чисел?
Меня часто донимали размышление о том, насколько эффективно Python использует память по сравнению с другими языками программирования. Например, сколько памяти нужно, чтобы работать с 1 миллионом целых чисел? А с тем же количеством строк произвольной длины?
Как оказалось, в Python есть возможность получить необходимую информацию прямо из интерактивной консоли, не обращаясь к исходному коду на C (хотя, для верности, мы туда все таки заглянем).
Удовлетворив любопытство, мы залезем внутрь типов данных и узнаем, на что именно расходуется память.
Все примеры были сделаны в CPython версии 2.7.4 на 32 битной машине. В конце приведена таблица для потребности в памяти на 64 битной машине.
Необходимые инструменты
sys.getsizeof и метод __sizeof__()
Первый инструмент, который нам потребуется находится в стандартной библиотеки sys. Цитируем официальную документацию:
Возвращает размер объекта в байтах.
Если указано значение по умолчанию, то оно вернется, если объект не предоставляет способа получить размер. В противном случае возникнет исключение TypeError.
Getsizeof() вызывает метод объекта __sizeof__ и добавляет размер дополнительной информации, которая хранится для сборщика мусора, если он используется.
Алгоритм работы getsizeof(), переписанной на Python, мог бы выглядеть следующем образом:
Где PyGC_Head — элемент двойного связанного списка, который используется сборщиком мусора для обнаружения кольцевых ссылок. В исходном коде он представлен следующей структурой:
Размер PyGC_Head будет равен 12 байт на 32 битной и 24 байта на 64 битной машине.
Попробуем вызвать getsizeof() в консоли и посмотрим, что получится:
За исключением магии с проверкой флагов, все очень просто.
Как видно из примера, int и float занимают 12 и 16 байт соответственно. Str занимает 21 байт и еще по одному байту на каждый символ содержимого. Пустой кортеж занимает 12 байт, и дополнительно 4 байта на каждый элемент. Для простых типов данных (которые не содержат ссылок на другие объекты, и соответственно, не отслеживаются сборщиком мусора), значение sys.getsizeof равно значению, возвращаемого методом __sizeof__().
id() и ctypes.string_at
Теперь выясним, на что именно расходуется память.
Для этого нужно нам нужны две вещи: во-первых, узнать, где именно хранится объект, а во-вторых, получить прямой доступ на чтение из памяти. Несмотря на то, что Python тщательно оберегает нас от прямого обращения к памяти, это сделать все таки возможно. При этом нужно быть осторожным, так как это может привести к ошибке сегментирования.
Встроенная функция id() возвращает адрес памяти, где храниться начала объекта (сам объект является C структурой)
Чтобы считать данные по адресу памяти нужно воспользоваться функцией string_at из модуля ctypes. Ее официальное описание не очень подробное:
ctypes.string_at(адрес[, длина])
Это функция возвращает строку, с началом в ячейки памяти «адрес». Если «длина» не указана, то считается что строка zero-terminated,
Теперь попробуем считать данные по адресу, который вернул нам id():
Вид шестнадцатеричного кода не очень впечатляет, но мы близки к истине.
Модель Struct
Для того чтобы представить вывод в значения, удобные для восприятия, воспользуемся еще одним модулем. Здесь нам поможет функция unpack() из модуля struct.
struct
Этот модуль производит преобразование между значениями Python и структурами на C, представленными в виде строк.
struct.unpack(формат, строка)
Разбирает строку в соответствие с данным форматов. Всегда возвращает кортеж, даже если строка содержит только один элемент. Строка должна содержать в точности то количество информации, как описано форматом.
Форматы данных, которые нам потребуются.
символ | Значение C | Значение Python | Длина на 32битной машине |
c | char | Строка из одного символа | 1 |
i | int | int | 4 |
l | long | int | 4 |
L | unsigned long | int | 4 |
d | double | float | 8 |
Теперь собираем все вместе и посмотрим на внутреннее устройство некоторых типов данных.
О формате значений несложно догадаться.
Первое число (373) — количество указателей, на объект.
Как видно, число увеличилось на единицу, после того как мы создали еще одну ссылку на объект.
Второе число (136770080) — указатель (id) на тип объекта:
Третье число (1) — непосредственно содержимое объекта.
Наши догадки можно подтвердить, заглянув в исходный код CPython
Здесь PyObject_HEAD — макрос, общий для всех встроенных объектов, а ob_ival — значение типа long. Макрос PyObject_HEAD добавляет счетчик количества указателей на объект и указатель на родительский тип объекта — как раз то, что мы и видели.
Float
Число с плавающей запятой очень похоже на int, но представлено в памяти C значением типа double.
В этом легко убедиться:
Строка (Str)
Строка представлена в виде массива символов, оканчивающимся нулевым байтом. Также в структуре строки отдельного сохраняется ее длина, хэш от ее содержания и флаг, определяющий, хранится ли она во внутреннем кэше interned.
Макрос PyObject_VAR_HEAD включает в себя PyObject_HEAD и добавляет значение long ob_ival, в котором хранится длина строки.
Четвертое значение соответствует хэшу от строки, в чем нетрудно убедиться.
Как видно, значение sstate равно 0, так что строка сейчас не кэшируется. Попробуем ее добавить в кэш:
Кортеж (Tuple)
Кортеж представлен в виде массива из указателей. Так как его использование может приводить к возникновению кольцевых ссылок, он отслеживается сборщиком мусора, на что расходуется дополнительная память (об этом нам напоминает вызов sys.getsizeof())
Структура tuple похоже на строку, только в ней отсутствуют специальные поля, кроме длины.
Как видим из примера, последние три элементы кортежа являются указателями на его содержимое.
Остальные базовые типы данных (unicode, list, dict, set, frozenset) можно исследовать аналогичным образом.
Что в итоге?
Тип | Имя в CPython | формат | Формат, для вложенных объектов | Длина на 32bit | Длина на 64bit | Память для GC* |
Int | PyIntObject | LLl | 12 | 24 | ||
float | PyFloatObject | LLd | 16 | 24 | ||
str | PyStringObject | LLLli+c*(длина+1) | 21+длина | 37+длина | ||
unicode | PyUnicodeObject | LLLLlL | L*(длина+1) | 28+4*длина | 52+4*длина | |
tuple | PyTupleObject | LLL+L*длина | 12+4*длина | 24+8*длина | Есть | |
list | PyListObject | L*5 | L*длину | 20+4*длина | 40+8*длина | Есть |
Set/ frozenset | PySetObject | L*7+(lL)*8+lL | LL* длина | ( 5 элементов) 100+8*длина | ( 5 элементов) 200+16*длина | Есть |
dict | PyDictObject | L*7+(lLL)*8 | lLL*длина | ( 5 элементов) 124+12*длина | ( 5 элементов) 248+24*длина | Есть |
* Добавляет 12 байт на 32 битной машине и 32 байта на 64 битной машине
Мы видим, что простые типы данных в Python в два-три раза больше своих прототипов на C. Разница обусловлена необходимостью хранить количество ссылок на объект и указатель на его тип (содержимое макроса PyObject_HEAD). Частично это компенсируется внутренним кэшированием, который позволяет повторно использовать ранее созданные объекты (это возможно только для неизменяемых типов).
Для строк и кортежей разница не такая значительная — добавляется некоторая постоянная величина.
А списки, словари и множества, как правило, занимают больше на 1/3, чем необходимо. Это обусловлено реализацией алгоритма добавления новых элементов, который приносит в жертву память ради экономии времени процессора.
Итак, отвечаем на вопрос в начале статьи: чтобы сохранить 1 миллион целых чисел нам потребуется 11.4 мегабайт (12*10^6 байт) на сами числа и дополнительно 3.8 мегабайт (12 + 4 + 4*10^6 байт) на кортеж, которых будет хранить на них ссылки.
UPD: Опечатки.
UPD: В подзаголовке «1 миллион целых чисел», вместо «1 миллион простых чисел»
Как работает память в Python
Что такое память и зачем она нужна?
Ни одна компьютерная программа не может работать без данных. А данные, чтобы программа имела к ним доступ, должны располагаться в оперативной памяти вашего компьютера. Но что такое оперативная память на самом деле? Когда произносишь это словосочетание, многие сразу представляют «железную» плашку, вставленную в материнскую плату, на которой написано что-то типа 16Gb DDR4 2666MHz. И они, разумеется, правы — это действительно физический блок оперативной памяти, в котором, в итоге, все данные и оказываются. Но прежде, чем стать доступной внутри вашей программы, на память (как и на всё остальное аппаратное обеспечение) накладывается куча абстракций.
Во-первых, есть операционная система, которая посредством диспетчера (менеджера) оперативной памяти абстрагирует физическую оперативную память, позволяет реализовать механизмы SWAP-файлов и др.
Во-вторых, внутри операционной системы, есть абстракция, называемая «процесс», которая, в том числе, предназначена для изоляции и упрощения работы с ресурсами отдельными программами. Именно процесс «делает вид», что независимо от количества запущенных программ и объема установленной в компьютер оперативной памяти, вашей программе доступно 4 ГБ RAM (на 32-разрядных системах) или сильно больше (на 64-разрядных).
Долгое время, компьютерным программам, хватало этого набора абстракций. Вам нужны данные, вы хотите с ними поработать? Всё просто:
Попросите у процесса (ОС) выделить вам немного (одну или несколько страниц) оперативной памяти.
Верните ее в операционную систему.
Такой подход позволял работать с памятью максимально эффективно. Вы точно знали, сколько реально вам памяти выделено, зачем вы ее используете, и т.д. и т.п. Однако помимо преимуществ подход имеет и ряд недостатков. Ключевой из них — сложность. Управлять памятью вручную — это сложно и тяжело. Легко забыть что-то удалить или вернуть страницу операционной системе, и сразу возникает утечка: программа держит неиспользуемую память просто так, не давая применять ее для решения других задач.
Время шло, и в борьбе с этой проблемой постепенно появлялись новые инструменты и возможности. Одной из них стала концепция управляемой памяти, когда не вы, а runtime или библиотеки вашего языка программирования самостоятельно занимаются очисткой памяти. Это, пускай и жертвуя немного эффективностью, позволило сильно сократить трудозатраты на управление памятью и повысить надежность этого процесса.
Механизм памяти в Python
Python — это язык с управляемой памятью. Причем для управления ею он использует несколько механизмов. Я постараюсь коротко осветить основные из них. Для зануд сразу подчеркну, что под Python’ом я понимаю конкретную реализацию CPython, а не какие-то другие версии, в которых всё может работать по-другому 🙂
При запуске Python-программы создается новый процесс, в рамках которого операционная система выделяет пул ресурсов, включая виртуальное адресное пространство. В эту память загружается интерпретатор Python вместе со всеми необходимыми ему для работы данными, включая код вашей программы.
Оставшаяся свободная виртуальная память может использоваться для хранения информации об объектах Python’а. Для управления этой памятью в CPython используется специальный механизм, который называется аллокатор. Он используется каждый раз, когда вам нужно создать новый объект.
Обычно мы в своих программах не оперируем большими объектами. Большая часть наших данных — это числа, строки и т.п., они занимают не такой уж большой объем в расчёте на одно значение. Но зато мы создаем их достаточно часто. И это приводило бы к проблемам, если бы Python абсолютно все такие вызовы транслировал в операционную систему. Системный вызов на выделение памяти — штука трудозатратная, зачастую связанная с переходом в контекст ядра операционной системы и т.п. Поэтому одна из главных задач аллокатора Python — оптимизация количества системных вызовов.
Ремарка. Для больших объектов (больше 512 байт) Python выделяет память напрямую у ОС. Обычно таких объектов не очень много в рамках программы, и создаются они нечасто. Поэтому накладные расходы на создание таких объектов напрямую в RAM не так высоки.
Как же устроен аллокатор внутри? Он состоит из частей трёх видов:
Арена — большой непрерывный кусок памяти (обычно 256 килобайт), содержит несколько страниц виртуальной памяти операционной системы.
Пул — одна страница виртуальной памяти (обычно 4 килобайта).
Блок — маленький кусочек памяти, используемый для хранения одного объекта.
Давайте поговорим о них подробнее. Когда Python’у нужно расположить какой-то объект в оперативной памяти он ищет подходящую арену. Если такой нету, он запрашивает новую арену у операционной системы. Что значит «подходящую»? Арены организованы в двусвязный список и отсортированы от самой заполненной к самой свободной. Для добавления нового объекта выбирается САМАЯ ЗАПОЛНЕННАЯ арена. Почему такая странная логика? Это связано с политикой освобождения памяти. Арены — единственное место, в котором происходит запрос и освобождение памяти в Python. Если какая-то арена полностью освобождается от объектов, она возвращается назад операционной системе и память освобождается. Таким образом, чем компактнее живут объекты в рамках арен, тем ниже общее потребление оперативной памяти программой.
Внутри арен расположены пулы. Каждый пул предназначен для хранения блоков одинакового размера (то есть в одном пуле могут быть только блоки одного и тоже размера, при этом в арене могут быть пулы с разными размерами блоков).
Каждый пул может быть в одном из трех состояний — used, full и empty. Full означает, что пул полностью занят и не может принять новые объекты. Empty — что пул полностью пустой и может быть использован для хранения новых объектов (при этом пустой пул может быть использован для хранения объектов любого размера). Used — это пул, который используется для хранения объектов, но при этом еще не заполнен полностью.
Python поддерживает списки пулов каждого из размеров и отдельно список пустых пулов. Если вы хотите создать новый объект, то Python сначала пытается найти used-пул с нужным размером блока и расположить объект там. Если used-пула нет, тогда берется empty-пул, и объект создается в нем (а сам пул из статуса empty переводится в статус used). Если и empty-пулов нет, то запрашивается новая арена.
Внутри пула живут блоки. Каждый блок предназначен для хранения одного объекта. В целях унификации размеры блоков фиксированы. Они могут быть размером 8, 16, 24, 32 …. 512 байт. Если Вам нужно 44 байта для хранения объекта, то он будет расположен в блоке на 48 байт. Каждый пул содержит список свободных и занятых блоков (на самом деле, есть еще untouched-блоки, в которых никогда не жили данные, но по сценариям использования они похожи на free-блоки, поэтому не будем на них останавливаться подробно). Когда вы хотите разместить новый объект, берется первый свободный блок, и объект располагается в нем. Когда объект удаляется, его блок помещается в список свободных.
Время жизни объекта и при чем тут GIL
Поговорив о том, как происходит выделение и освобождение памяти в Python, стоит отдельно поговорить о том, а как язык управляет временем жизни. Для этого в Python реализовано два механизма:
Механизм сборки мусора.
Каждый объект в Python — это, в первую очередь, объект, унаследованный от базового класса PyObject. Этот самый PyObject содержит всего два поля: ob_refcnt — количество ссылок, и ob_type — указатель на другой объект, тип данного объекта.
К сожалению, счетчик ссылок подвержен проблемам в многопоточной среде. Состояния гонки могут приводит к некорректности обновления этого счетчика из разных потоков. Чтобы этого избежать CPython использует GIL — Global Interpreter Lock. Каждый раз, когда происходит работа с памятью, GIL — как глобальная блокировка — препятствует выполнению этих действий одновременно из двух потоков. Он гарантирует, что сначала отработает один, потом другой.
Второй механизм очистки памяти — это сборщик мусора (garbage collector), основанный на идее поколений. Зачем он нам нужен, если есть счетчик ссылок? Дело в том, что счетчик ссылок не позволяет отслеживать ситуации с кольцевыми зависимостями, когда объект A содержит ссылку на B, а B — на A. В таком случае, даже если никто больше не ссылается на объекты A и B, их счетчик всё равно никогда не станет равным нулю и они никогда не удалятся через механизм счетчика ссылок. Для борьбы с такими зависимостями и появился второй механизм (как модуль gc, начиная с версии Python 1.5).
Работает он следующим образом: GC отслеживает объекты (объекты-контейнеры, которые могут содержать ссылки на другие объекты) и смотрит, доступны ли они из основного кода на Python. Если нет, то сборщик их удаляет. Если да — оставляет.
В отличие от счетчика ссылок, механизм сборки мусора не работает постоянно. Он запускается от случая к случаю на основе эвристик. GC разделяет объекты на три поколения. Каждый новый объект начинает свой путь с первого поколения. Если объект переживает раунд сборки мусора, он переходит к более старому поколению. В младших поколениях сборка происходит чаще, чем в старших. Эта практика является стандартной для такого рода сборщиков мусора и снижает частоту и объем очищаемых данных. Идея простая: чем дольше живет объект, тем с большей вероятностью он проживет еще. То есть новые объекты удаляются гораздо чаще, чем те, которые уже просуществовали достаточно долго.
Каждое поколение имеет индивидуальный счётчик и порог. Счетчик хранит количество созданных минус количество удаленных объектов с момента последнего сбора. Каждый раз, когда вы создаете новый объект, Python проверяет, не превысил ли счетчик поколения пороговое значение. Если это так, Python инициирует процесс сборки мусора.
Итоги
Вот так и устроен процесс работы с памятью в Python. Его понимание помогает лучше понять, как работают ваши программы внутри, а значит и писать более эффективный и высокопроизводительный код.
Что осталось за рамками статьи?
Мы не поговорили еще о многих вещах:
Об оптимизации хранения небольших чисел.
О работе с большими файлами через Memory Mapping.
Управление памятью в Python
Одна из главных проблем при написании крупных (относительно) программ на Python — минимизация потребления памяти. Однако управлять памятью здесь легко — если вас вообще это волнует. Память в Python выделяется прозрачно, управление объектами происходит с помощью системы счётчиков ссылок (reference count), и память высвобождается, когда счётчик падает до нуля. В теории всё прекрасно. А на практике вам нужно знать несколько вещей об управлении памятью в Python, чтобы ваши программы эффективно её использовали. Первая вещь, надо хорошо в ней разбираться: размеры основных объектов в Python. И вторая вещь: как устроено управление «под капотом» языка.
Начнём с размеров объектов. В Python есть много примитивных типов данных: целые числа (int), long (версия int с неограниченной точностью), числа с плавающей запятой (они же числа с двойной точностью, double), кортежи (tuple), строковые значения, списки, словари и классы.
Основные объекты
Давайте напишем функцию, показывающую размер объектов (рекурсивно, если нужно):
Теперь с помощью этой функции можно исследовать размеры основных типов данных:
Если у вас 32-битный Python 2.7x, то вы увидите:
А если 64-битный Python 2.7x, то увидите:
Давайте сосредоточимся на 64-битной версии (в основном потому, что в нашем случае она более востребована). None занимает 16 байтов. int — 24 байта, в три раза больше по сравнению с int64_t в языке С, хотя это в какой-то мере machine-friendly целое число. Минимальный размер значений типа long (с неограниченной точностью), используемых для представления чисел больше 2 63 – 1, это — 36 байтов. Затем они увеличиваются линейно, как логарифм представляемого числа.
Числа с плавающей запятой в Python зависят от реализации, но похожи на числа с двойной точностью в C. Однако они не занимают всего лишь 8 байтов:
На 32-битной платформе выдаёт:
Это опять втрое больше, чем предположил бы программист на C. А что насчёт строковых значений?
На 32-битной платформе:
Пустое строковое значение занимает 37 байтов в 64-битной среде! Затем потребление памяти увеличивается в соответствии с размером (полезного) значения.
Давайте разберёмся и с другими часто востребованными структурами: кортежами, списками и словарями. Списки (реализованные как списки массивов, а не как связные списки, со всеми вытекающими) — это массивы ссылок на Python-объекты, что позволяет им быть гетерогенными. Их размеры:
Пустой список занимает 72 байта. Размер пустого std::list() в 64-битном С — всего 16 байтов, в 4—5 раз меньше. Что насчёт кортежей? И словарей?
На 32-битной платформе выдаёт:
Последний пример особенно интересен, потому что он «не складывается». Пары ключ/значение занимают 72 байта (их компоненты занимают 38 + 24 = 62 байта, а ещё 10 тратится на саму пару), но весь словарь весит уже 280 байтов (а не минимально необходимые 144 = 72 × 2 байта). Словарь считается эффективной структурой данных для поиска, и две вероятные реализации будут занимать памяти больше, чем необходимый минимум. Если это какое-то дерево, то приходится расплачиваться за внутренние ноды, содержащие ключ и два указателя на дочерние ноды. Если это хеш-таблица, то ради хорошей производительности нужно иметь место для свободных записей.
Эквивалентная (относительно) структура std::map из C++ при создании занимает 48 байтов (пока ещё пустая). А пустое строковое значение в C++ требует 8 байтов (затем размер линейно растёт вместе с размером строки). Целочисленное значение — 4 байта (32 бит).
И что нам всё это даёт? Тот факт, что пустое строковое значение занимает 8 или 37 байтов, мало что меняет. Действительно. Но лишь до тех пор, пока ваш проект не начнёт разрастаться. Тогда вам придётся очень аккуратно следить за количеством создаваемых объектов, чтобы ограничить объём потребляемой приложением памяти. Для настоящих приложений это проблема. Чтобы разработать действительно хорошую стратегию управления памятью, нам нужно следить не только за размером новых объектов, но и за количеством и порядком их создания. Для Python-программ это очень важно. Давайте теперь разберёмся со следующим ключевым моментом: с внутренней организацией выделения памяти в Python.
Внутреннее управление памятью
Чтобы ускорить выделение памяти (и её повторное применение), Python использует ряд списков для маленьких объектов. Каждый список содержит объекты одного размера: может быть один список для объектов от 1 до 8 байтов, другой — для объектов 9—16 байтов и т. д. Когда нужно создать маленький объект, мы вновь используем свободный блок в списке или выделяем новый.
Есть несколько нюансов, как Python распределяет эти списки по блокам, пулам и «аренам»: несколько блоков формируют пул, пулы собираются в арену и т. д. Но мы в это углубляться не будем (если хотите, то можете почитать мысли Эвана Джонса о том, как улучшить выделение памяти в Python). Нам важно знать, что эти списки неуменьшаемы.
В самом деле: если элемент (размером x) удалён из памяти (стёрта ссылка на него), то занимавшийся им объём не возвращается в пул глобальной памяти Python (в том числе и в систему), а помечается свободным и добавляется к списку свободных элементов размером x. Занимаемый мёртвым объектом объём может быть использован вновь, если понадобится другой объект подходящего размера. А если подходящего мёртвого объекта нет, то создаётся новый.
Если память с маленькими объектами никогда не освобождается, то мы приходим к неизбежному выводу, что эти списки с маленькими объектами могут только расти, они никогда не уменьшаются, а значит, в каждый момент времени в памяти вашего приложения преобладают размещённые в ней многочисленные маленькие объекты.
Следовательно, старайтесь размещать в памяти только то количество маленьких объектов, которое нужно для какой-то одной задачи, отдавая предпочтение циклам, в которых создаётся/обрабатывается небольшое количество элементов, а не паттернам, где списки сначала создаются с помощью синтаксиса генерирования, а потом обрабатываются.
Хотя второй вариант более соответствует духу Python, он менее удачен: в конце концов появится большое количество маленьких объектов, которые заполнят соответствующие списки, и даже если какой-то список станет мёртвым, то объекты в нём (теперь уже все находящиеся в списке свободных объектов) всё ещё будут занимать много памяти.
Увеличить списки свободных элементов — не особая проблема, потому что эта память всё ещё доступна для Python-программы. Но с точки зрения ОС размер вашей программы равен общему размеру выделенной для Python памяти. И только под Windows память возвращается в кучу ОС (и применяется для размещения и других объектов, помимо маленьких), а под Linux общий объём используемой вашим приложением памяти будет только расти.
На 64-битном компьютере она выводит:
Программа создаёт n = 1 000 000 целых чисел (n × 24 байта =
23 Мб) и дополнительный список ссылок (n × 8 байтов =
7,6 Мб), и в сумме получаем
31 Мб. copy.deepcopy копирует оба списка, и копии занимают
В этом примере в сумме занято
73 Мб, что более чем вдвое превышает объём, необходимый для хранения списка, весящего
31 Мб. Как видите, при потере бдительности порой возникают очень неприятные сюрпризы с точки зрения потребления памяти!
Вы можете получить иные результаты на других платформах и других версиях Python.
Pickle
Pickle — стандартный способ (де)сериализации Python-объектов в файл. Каково его потребление памяти? Он создаёт дополнительные копии данных или работает умнее? Рассмотрим короткий пример:
При первом вызове мы профилируем создание pickled-данных, а при втором вызове заново считываем их (можно закомментировать функцию, чтобы она не вызывалась). При использовании memory_profiler в ходе создания данных потребляется много памяти:
А при считывании — немного меньше:
Так что picklе очень плохо влияет на потребление памяти. Исходный список занимает около 230 Мб, а при сериализации потребляется ещё примерно столько же.
C другой стороны, десериализация выглядит более эффективной. Потребляется больше памяти, чем исходный список (300 Мб вместо 230), но это хотя бы не вдвое больше.
В целом лучше избегать (де)сериализации в приложениях, чувствительных к потреблению памяти. Какие есть альтернативы? Сериализация сохраняет всю структуру данных, так что позднее вы сможете полностью восстановить её из получившегося файла. Но это не всегда нужно. Если файл содержит список, как в предыдущем примере, то, возможно, целесообразно использовать простой, текстовый формат. Давайте посмотрим, что это даёт.
Простейшая (naïve) реализация:
При записи потребляется гораздо меньше памяти. Всё ещё создаётся много временных маленьких объектов (примерно 60 Мб), но это не сравнить с удвоенным потреблением. Чтение сравнимо по затратам (используется чуть меньше памяти).
Или, ещё лучше, применяйте массивы Numpy (или PyTables). Но это уже совсем другая история. В то же время в директории Theano/doc/tutorial вы можете почитать другое руководство по загрузке и сохранению.
Цели архитектуры Python никак не совпадают, допустим, с целями архитектуры C. Последний спроектирован так, чтобы дать вам хороший контроль над тем, что вы делаете, за счёт более сложного и явного программирования. А первый спроектирован так, чтобы вы могли писать код быстрее, но при этом язык прячет большинство подробностей реализации (если не все). Хотя это звучит красиво, но игнорирование неэффективных реализаций языка в production-среде порой приводит к неприятным последствиям, иногда неисправимым. Надеюсь, что знание этих особенностей Python при работе с памятью (архитектурных особенностей!) поможет вам писать код, который будет лучше соответствовать требованиям production, хорошо масштабироваться или, напротив, окажется горящим адом для памяти.