Верно ли что python очень экономно расходует оперативную память во время выполнения программы

Обновлено: 04.07.2024

В этом руководстве мы узнаем, как Python управляет памятью или как обрабатывает наши данные внутренне. Это руководство даст глубокое понимание основ управления памятью Python. Когда мы выполняем наш скрипт, в его памяти запускается много логических процессов, чтобы сделать код эффективным.

Вступление

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

В Python памятью управляет менеджер Python, который определяет, куда поместить данные приложения в память.

Предположим, что память выглядит как пустая книга, и мы хотим написать что-нибудь на странице книги. Когда мы записываем любые данные, менеджер находит свободное место в книге и передает их приложению. Процедура предоставления памяти объектам называется распределением.

С другой стороны, когда данные больше не используются, они могут быть удалены менеджером памяти Python. Но вопрос в том, как? А откуда взялось это воспоминание?

Распределение памяти Python

  • Распределение статической памяти
  • Распределение динамической памяти

Статической памяти

Размещение стека

Структура данных Stack используется для хранения статической памяти. Это необходимо только внутри конкретной функции или вызова метода. Функция добавляется в стек вызовов программы всякий раз, когда мы ее вызываем. Назначение переменной внутри функции временно сохраняется в стеке вызова функции; функция возвращает значение, и стек вызовов переходит к текстовой задаче. Компилятор обрабатывает все эти процессы, поэтому нам не нужно об этом беспокоиться.

Стек вызовов(структура данных стека) содержит рабочие данные программы, такие как подпрограммы или вызовы функций, в порядке их вызова. Эти функции выходят из стека при вызове.

Динамической памяти

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

Распределение памяти в куче

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

В приведенном ниже примере мы определяем, как переменная функции хранится в стеке и куче.

Реализация Python по умолчанию

В основном язык Python написан на английском языке. Однако это определено в справочном руководстве, что само по себе бесполезно. Итак, нам нужен код, основанный на интерпретаторе правила в руководстве.

Преимущество реализации по умолчанию заключается в том, что она выполняет код Python на компьютере, а также преобразует наш код Python в инструкции. Итак, мы можем сказать, что реализация Python по умолчанию удовлетворяет обоим требованиям.

Программа, которую мы пишем на языке Python, сначала преобразует в байт-код компьютерных инструкций. Виртуальная машина интерпретирует этот байт-код.

Сборщик мусора Python

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

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

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

Объекты Python в памяти

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

Давайте разберемся в следующем примере.

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

Следовательно, сборщик мусора Python работает автоматически, и программистам не нужно беспокоиться об этом, в отличие от C.

Подсчет ссылок в Python

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

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

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

Подсчет ссылок

Когда мы присваиваем значение переменной x, целочисленный объект 10 создается в памяти кучи, и его ссылка присваивается x.

y = x

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

Теперь посмотрим на другой пример.

Новый объект ссылки

Переменные x и y не ссылаются на один и тот же объект, потому что x увеличивается на единицу, x создает новый объект ссылки, а y по-прежнему ссылается на 10.

Преобразование сборщика мусора

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

Сборщик мусора вступает в действие, если превышен порог количества выделений за вычетом числа отмененных.

Мы можем изменить пороговое значение вручную с помощью модуля GC. Этот модуль предоставляет метод get_threshold() для проверки порогового значения другого поколения сборщика мусора. Давайте разберемся в следующем примере.

Пороговое значение для запуска сборщика мусора можно изменить с помощью метода set_threshold().

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

Сборщик мусора Python обрабатывает низкоуровневые детали для разработчика.

Важность выполнения ручной сборки мусора

Как мы обсуждали ранее, интерпретатор Python обрабатывает ссылку на объект, используемый в программе. Он автоматически освобождает память, когда счетчик ссылок становится равным нулю. Это классический подход для подсчета ссылок, если он не работает, когда программа ссылается на циклы. Цикл ссылок происходит, когда один или несколько объектов ссылаются друг на друга. Следовательно, счетчик ссылок никогда не становится нулевым.

Мы создали справочный цикл. Объект list1 ссылается на сам объект list1. Когда функция возвращает объект list1, память для объекта list1 не освобождается. Так что подсчет ссылок не подходит для решения ссылочного цикла. Но мы можем решить эту проблему, изменив сборщик мусора или производительность сборщика мусора.

Для этого мы будем использовать функцию gc.collect() для модуля gc.

Приведенный выше код даст количество собранных и освобожденных объектов.

Метод gc.collect() используется для выполнения сборки мусора по времени. Этот метод вызывается через фиксированный интервал времени для выполнения сборки мусора на основе времени.

При четной сборке мусора функция gc.collect() вызывается после возникновения события. Давайте разберемся в следующем примере.

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

Управление памятью в Python

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

Python использует часть памяти для внутреннего использования и необъектную память. Другая часть памяти используется для объекта Python, такого как int, dict, list и т. д.

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

Стратегия выделения памяти CPython состоит из трех основных компонентов.

Распространенные способы уменьшить сложность пространства

Мы можем следовать некоторым передовым методам, чтобы уменьшить сложность пространства. Эти методы должны сэкономить достаточно места и сделать программу эффективной. Ниже приведены несколько практик в Python для распределителей памяти.

Мы определяем список на Python; распределитель памяти распределяет память кучи соответственно индексации списка. Предположим, нам нужен подсписок к данному списку, затем мы должны выполнить нарезку списка. Это простой способ получить подсписок из исходного списка. Он подходит для небольшого количества данных, но не подходит для емких данных.

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

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

Разработчик должен попытаться использовать «для элемента в массиве» вместо «для индекса в диапазоне(len(array))» для экономии места и времени. Если программе не нужна индексация элемента списка, не используйте ее.

Конкатенация строк не подходит для экономии места и времени. По возможности, мы должны избегать использования знака «+» для конкатенации строк, потому что строки неизменяемы. Когда мы добавляем новую строку к существующей строке, Python создает новую строку и присваивает ее новому адресу.

Каждая строка требует фиксированного размера памяти в зависимости от символа и его длины. Когда мы меняем строку, ей требуется другой объем памяти и ее необходимо перераспределить.

Мы создали переменную a для ссылки на строковый объект, который является информацией о строковом значении.

Затем мы добавляем новую строку с помощью оператора «+». Python перераспределяет новую строку в памяти в зависимости от ее размера и длины. Предположим, что размер памяти исходной строки равен n байтам, тогда новая строка будет размером m байтов.

Вместо использования конкатенации строк мы можем использовать «.join(iterable_object)» или формат или%. Это оказывает огромное влияние на экономию памяти и времени.

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

В следующем примере мы реализуем итератор, который вызывает специальную функцию генератора. Ключевое слово yield возвращает текущее значение, переходя к следующему значению только на следующей итерации цикла.

  • По возможности используйте встроенную библиотеку

Если мы используем методы, которые уже были предопределены в библиотеке Python, импортируйте соответствующую библиотеку. Это сэкономит много места и времени. Мы также можем создать модуль, чтобы определить функцию и импортировать ее в текущую рабочую программу.

Заключение

Python - это фантастический язык программирования. Он также известен как довольно медленный, в основном из-за его огромной гибкости и динамических характеристик. Для многих приложений и областей это не проблема из-за их требований и различных методов оптимизации. Менее известно, что графы объектов Python (вложенные словари списков, кортежей и примитивных типов) занимают значительный объем памяти. Это может быть гораздо более серьезным ограничивающим фактором из-за его влияния на кеширование, виртуальную память, многопользовательскую работу с другими программами и в целом более быстрое исчерпание доступной памяти, которая является дефицитным и дорогим ресурсом.

Оказывается, нетривиально выяснить, сколько памяти фактически потребляется. В этой статье я расскажу о тонкостях управления памятью объекта Python и покажу, как точно измерить потребляемую память.

В этой статье я остановлюсь исключительно на CPython - основной реализации языка программирования Python. Эксперименты и выводы здесь не относятся к другим реализациям Python, таким как IronPython, Jython и PyPy.

Также я запустил числа на 64-битном Python 2.7. В Python 3 числа иногда немного отличаются (особенно для строк, которые всегда являются Unicode), но концепции одинаковы.

Практическое исследование использования памяти Python

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

Встроенная функция sys.getsizeof()

Модуль sys стандартной библиотеки предоставляет функцию getsizeof(). Эта функция принимает объект (и необязательный параметр по умолчанию), вызывает метод sizeof() объекта и возвращает результат, поэтому вы также можете сделать ваши объекты инспектируемыми.

Измерение памяти объектов Python

Давайте начнем с некоторых числовых типов:

Интересно. Целое число занимает 24 байта.

Хм . float также занимает 24 байта.

Вот это да. 80 байтов! Это действительно заставляет задуматься о том, хотите ли вы представлять большое количество вещественных чисел как числа с плавающей запятой или десятичные дроби.

Давайте перейдем к строкам и коллекциям:

Хорошо. Пустая строка занимает 37 байтов, и каждый дополнительный символ добавляет еще один байт. Это многое говорит о компромиссе между сохранением нескольких коротких строк, когда вы будете платить 37 байтов за каждую, а не одну длинную строку, где вы платите только один раз.

Строки Unicode ведут себя аналогично, за исключением того, что служебные данные составляют 50 байтов, и каждый дополнительный символ добавляет 2 байта. Это стоит учитывать, если вы используете библиотеки, которые возвращают строки Unicode, но ваш текст может быть представлен в виде простых строк.

Кстати, в Python 3 строки всегда имеют Unicode, а служебные данные составляют 49 байт (они где-то сохранили байт). Объект байтов имеет служебную информацию только 33 байта. Если у вас есть программа, которая обрабатывает много коротких строк в памяти, и вы заботитесь о производительности, рассмотрите Python 3.

В чем дело? Пустой список занимает 72 байта, но каждый дополнительный int добавляет всего 8 байтов, где размер int составляет 24 байта. Список, который содержит длинную строку, занимает всего 80 байтов.

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

Наборы и словари якобы вообще не растут при добавлении элементов, но отмечают огромные накладные расходы.

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

Функция deep_getsizeof()

Теперь, когда я напугал вас до полусмерти и продемонстрировал, что sys.getsizeof() может только сказать вам, сколько памяти занимает примитивный объект, давайте посмотрим на более адекватное решение. Функция deep_getsizeof() рекурсивно выполняет детализацию и вычисляет фактическое использование памяти графом объектов Python.

У этой функции есть несколько интересных аспектов. Она учитывает объекты, на которые ссылаются несколько раз, и учитывает их только один раз, отслеживая идентификаторы объектов. Другая интересная особенность реализации заключается в том, что она в полной мере использует абстрактные базовые классы модуля коллекций. Это позволяет функции очень лаконично обрабатывать любую коллекцию, которая реализует базовые классы Mapping или Container, вместо непосредственного обращения к множеству типов коллекций, таких как: строка, Unicode, байты, список, кортеж, dict, frozendict, OrderedDict, set, frozenset и т.д.

Давайте посмотрим на это в действии:

Строка длиной 7 занимает 44 байта (37 служебных данных + 7 байтов для каждого символа).

Пустой список занимает 72 байта (только накладные расходы).

python deep_getsizeof ([x], set ()) 124

Список, содержащий строку x, занимает 124 байта (72 + 8 + 44).

Список, содержащий строку x 5 раз, занимает 156 байтов (72 + 5 * 8 + 44).

Последний пример показывает, что deep_getsizeof() подсчитывает ссылки на один и тот же объект (строку x) только один раз, но подсчитывается указатель каждой ссылки.

Баг или фича

Оказывается, что у CPython есть несколько хитростей, поэтому числа, которые вы получаете от deep_getsizeof(), не полностью отражают использование памяти программой Python.

Подсчет ссылок

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

Маленькие объекты

Целые числа

CPython хранит глобальный список всех целых чисел в диапазоне [-5, 256]. Эта стратегия оптимизации имеет смысл, потому что маленькие целые числа всплывают повсюду, и, учитывая, что каждое целое число занимает 24 байта, оно экономит много памяти для типичной программы.

Это также означает, что CPython предварительно выделяет 266 * 24 = 6384 байта для всех этих целых чисел, даже если вы не используете большинство из них. Вы можете проверить это с помощью функции id(), которая дает указатель на фактический объект. Если вы называете id(x) несколько для любого x в диапазоне [-5, 256], вы будете каждый раз получать один и тот же результат (для одного и того же целого числа). Но если вы попробуете это для целых чисел за пределами этого диапазона, каждый из них будет отличаться (новый объект создается на лету каждый раз).

Вот несколько примеров в этом диапазоне:

Вот несколько примеров за пределами диапазона:

Память Python против системной памяти

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

Профилировщик памяти

Чтобы измерить и измерить фактическое использование памяти вашей программой, вы можете использовать модуль memory_profiler. Я немного поиграл с этим, и я не уверен, что доверяю результатам. Он очень прост в использовании. Вы декорируете функцию (может быть главной (0 функция)) с помощью декоратора @profiler, и когда программа завершает работу, профилировщик памяти выводит на стандартный вывод удобный отчет, который показывает общее количество и изменения в памяти для каждой строки. Вот пример программы, которую я запускал под профилировщиком:

Как вы можете видеть, занято 22,9 МБ дополнительной памяти. Причина, по которой память не увеличивается при добавлении целых чисел как внутри, так и вне диапазона [-5, 256], а также при добавлении строки, заключается в том, что во всех случаях используется один объект. Непонятно, почему первый цикл диапазона (100000) в строке 8 добавляет 4,2 МБ, в то время как второй цикл в строке 10 добавляет всего 0,4 МБ, а третий цикл в строке 12 добавляет 0,8 МБ. Наконец, при удалении списков a, b и c освобождается -0.6MB для a и c, но для b добавляется 0.2MB. Я не могу понять эти странные результаты.

Заключение

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

Если вы работаете на современном компьютере и не пишите приложения схожие с 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:

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

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

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

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

В 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 многое делает за нас. Мы привыкли не заботиться об управлении памятью и о написании соответствующего кода. Пусть эти процессы и скрыты, но без их понимания трудно подготовить производительный код для высоконагруженных задач. В этой статье мы рассмотрим модель памяти Python и то, как интерпретатор Python взаимодействует с оперативной памятью компьютера.

Диспетчер памяти: «командовать парадом буду я»

Python — это интерпретируемый язык программирования, поэтому перед запуском программы код на языке Python компилируется в машиночитаемые инструкции — байт-код . Инструкции байт-кода интерпретируются виртуальной машиной, определяемой реализацией языка, например, стандартной — CPython .

Доклад Егора Овчаренко «Устройство CPython» поможет разобраться в стандартной реализации Python

Оговоримся, что CPython не взаимодействует напрямую с регистрами и ячейками физической памяти — только с ее виртуальным представлением. В начале выполнения программы операционная система создает новый процесс и выделяет под него ресурсы. Выделенную виртуальную память интерпретатор использует для 1) собственной корректной работы, 2) стека вызываемых функций и их аргументов и 3) хранилища данных, представленного в виде кучи .

В отличие от C/C++, мы не можем управлять состоянием кучи напрямую из Python. Функции низкоуровневой работы с памятью предоставляются Python/C API , но обычно интерпретатор просто обращается к хранилищу данных через диспетчер памяти Python (memory manager).

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

Фактически за это отвечает даже не диспетчер задач, который ожидает гостей за регистрационной стойкой, а GIL — глобальная блокировка интерпретатора. GIL гарантирует: в один и тот же момент времени байт-код выполняется только одним потоком. Главное преимущество — безопасная работа с памятью, а основной недостаток в том, что многопоточное выполнение программ Python требует специфических решений.

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

Организация доступной виртуальной памяти

Непосредственно с оперативной памятью взаимодействует распределитель сырой памяти (raw memory allocator). Поверх него работают аллокаторы, реализующие стратегии управления памятью, специфичные для отдельных типов объектов. Объекты разных типов — например, числа и строки — занимают разный объем, к ним применяются разные механизмы хранения и освобождения памяти. Аллокаторы стараются не занимать лишнюю память до тех пор, пока она не станет совершенно необходимой — этот момент определен стратегией распределения памяти CPython.

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

  • Арена — фрагмент памяти, расположенный в пределах непрерывного блока оперативной памяти объемом 256 Кб. Объекты размером более 256 Кб направляются в стандартный аллокатор C.
  • Пул — блок памяти внутри арены, занимающий 4 Кб, что соответствует одной странице виртуальной памяти. То есть одна арена включает до 256/4 = 64 пулов.
  • Блок — элемент пула размером от 16 до 512 байт. В пределах пула все блоки имеют одинаковый размер. Размер блока определяется тем, сколько байт требуется для представления конкретного объекта. Размеры блоков кратны 16 байт. То есть существует всего 512/16 = 32 классов ( size class ) блоков. То есть в одном пуле, в зависимости от класса, может находиться от 8 до 256 блоков.

Блок содержит не более одного объекта Python и находится в одном из трех состояний:

  1. untouched — блок еще не использовался для хранения данных;
  2. free — блок использовался механизмом памяти, но больше не содержит используемых программой данных;
  3. allocated — блок хранит данные, необходимые для выполнения программы.

В пределах пула блоки free организованы в односвязный список с указателем freeblock . Если аллокатору для выделения памяти не хватит блоков списка freeblock , он задействует блоки untouched . Освобождение памяти означает всего лишь то, что аллокатор меняет статус блока с allocated на free и начинает отслеживать блок в списке freeblock .

Пул может находиться в одном из трех состояний: used (занят), full (заполнен), empty (пуст). Пустые пулы отличаются от занятых отсутствием блоков allocated и тем, что для них пока не определен size class . Пулы full полностью заполнены блоками allocated и недоступны для записи. Стоит освободиться любому из блоков заполненного пула — и он помечается как used .

Пулы одного типа и одного размера блоков организованы в двусвязные списки. Это позволяет алгоритму легко находить доступное пространство для блока заданного размера. Алгоритм проверяет список usedpools и размещает блок в доступном пуле. Если в usedpools нет ни одного подходящего пула для запроса, алгоритм использует пул из списка freepools , который отслеживает пулы в состоянии empty .

Арена

Арены содержат пулы любых видов и организованы в двусвязный список usable_arenas . Список отсортирован по количеству доступных пустых пулов. Чем меньше в арене таких пулов, тем она ближе к началу списка. Для размещения новых данных выбирается область, наиболее заполненная данными.

Информацию о текущем распределении памяти в аренах, пулах и блоках можно посмотреть, запустив функцию sys._debugmallocstats() :

Чтобы не произошло утечки памяти, диспетчер памяти должен отследить, что вся выделенная память освободится после завершения работы программы. То есть при завершении программы CPython дает задание очистить все арены.

Именно количество используемых арен определяет объем оперативной памяти, занимаемой программой на Python — если в арене все пулы в состоянии empty , СPython делает запрос на освобождение этого участка виртуальной памяти. Но уже понятно: чтобы пулы стали empty , все их блоки должны быть free или untouched . Получается, нужно понять, как CPython освобождает память.

Освобождение памяти: счетчик ссылок, сборщик мусора

Для освобождения памяти используются два механизма: счетчик ссылок и сборщик мусора.

Всё в Python является объектами, а прародителем всех типов объектов в реализации CPython является PyObject . От него наследуются все остальные типы. В PyObject определены счетчик ссылок и указатель на фактический тип объекта. Счетчик ссылок увеличивается на единицу, когда мы создаем что-то, что обращается к объекту, например, сохраняем объект в новой переменной. И наоборот, счетчик уменьшается на единицу, когда мы перестаем ссылаться на объект.

Счетчик ссылок любого объекта можно проверить с помощью sys.getrefcount() . Учтите, что передача объекта в getrefcount() увеличивает счетчик ссылок на 1, так как сам вызов метода создает еще одну ссылку. Когда счетчик уменьшается до нуля, происходит вызов аллокатора для освобождения соответствующих блоков памяти.

Однако счетчик ссылок неспособен отследить ситуации с циклическими ссылками. К примеру, возможна ситуация, когда два объекта ссылаются друг на друга, но оба уже не используются программой. Для борьбы с такими зависимостями используется сборщик мусора ( garbage collector ).

Если счетчик ссылок является свойством объекта, то сборщик мусора — механизм, который запускается на основе эвристик. Задача этих эвристик — снизить частоту и объем очищаемых данных. Основная стратегия заключается в разделении объектов на поколения: чем больше сборок мусора пережил объект, тем он значимее для выполнения работы программы. Сборщик мусора имеет интерфейс в виде модуля gc .

Заключение

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

  1. Обращайте внимание на работу с неизменяемыми объектами . К примеру, вместо использования оператора + для соединения строк используйте методы .join() , .format() или f-строки.
  2. Избегайте вложенных циклов. Создание сложных вложенных циклов приводит к генерации чрезмерно большого количества объектов, занимающих значительную часть виртуальной памяти. Большинство задач, решаемых с помощью вложенных циклов, разрешимы методами модуля itertools.
  3. Используйте кэширование . Если вы знаете, что функция или класс используют или генерируют набор однотипных объектов, применяйте кэширование . Часто для этого достаточно добавить всего лишь один декоратор из библиотеки functools .
  4. Профилируйте код. Если программа начинает «тормозить», то профилирование — самый быстрый способ найти корень всех зол.

На ранних этапах работы с кодом Python можно вполне обойтись стандартными средствами Python. Но по мере разрастания кодовой базы и приближения к коммерческому использованию кода производительность становится одним из ключевых факторов. Многие задачи, связанные с производительностью Python, лежат в области понимания устройства инструмента и конкретной реализации языка. Не пожалейте времени на изучение исходного кода CPython, находящегося в свободном доступе в репозитории на GitHub .

На Python создают прикладные приложения, пишут тесты и бэкенд веб-приложений, автоматизируют задачи в системном администрировании, его используют в нейронных сетях и анализе больших данных. Язык можно изучить самостоятельно, но на это придется потратить немало времени. Если вы хотите быстро понять основы программирования на Python, обратите внимание на онлайн-курс «Библиотеки программиста». За 30 уроков (15 теоретических и 15 практических занятий) под руководством практикующих экспертов вы не только изучите основы синтаксиса, но и освоите две интегрированные среды разработки (PyCharm и Jupyter Notebook), работу со словарями, парсинг веб-страниц, создание ботов для Telegram и Instagram, тестирование кода и даже анализ данных. Чтобы процесс обучения стал более интересным и комфортным, студенты получат от нас обратную связь. Кураторы и преподаватели курса ответят на все вопросы по теме лекций и практических занятий.

Читайте также: