January 7, 2020

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

Оглавление

  1. Выделение памяти
  2. Освобождение памяти. Сборка мусора
  3. Performance tips

Для чего эта статья?

Помимо расширения кругозора, эта статья нужна для:

    • написания эффективного кода
    • улучшения работы приложения

Для тех, кто колеблется - читать или нет - у меня есть совет: лучше никогда не заниматься анализом памяти в Python.

    1. Это неблагодарное дело.
    2. Проведи лучше это время с семьёй.

Выделение памяти


Существует два типа выделения памяти:

Static Memory Allocation

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

Stack используется для реализации статического размещения. В этом случае память повторно не может быть использована.

Dynamic Memory Allocation

Программа выделяет память во время выполнения.

Heap используется для реализации динамического выделения. В этом случае память может быть освобождена и использована повторно, когда это не требуется.


Хорошие новости! В Python всё является объектом. Это значит, что динамическое выделение памяти лежит в основе управления памятью в Python. Когда объекты больше не нужны, менеджер памяти автоматически извлекает их.

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

Эту систему мы можем изобразить как набор слоёв

Источник


Стратегия распределения памяти

Чтобы уменьшить расходы для маленьких объектов (менее 512 байт), Python выделяет большие блоки памяти. Крупными объектами занимается стандартный C распределитель.

Методы и переменные создаются в памяти стека. Фрейм стека создается всякий раз, когда создаются методы и переменные. Эти кадры уничтожаются автоматически при каждом возврате методов.
Объекты и экземпляры объектов создаются в памяти кучи (heap memory). Как только переменные и функции возвращаются, мертвые объекты будут собраны GC.


Распределение небольших объектов

Распределитель малых объектов использует три уровня абстракции - арена, пул и блок.

Блок

Блок - это кусок памяти определенного размера. Каждый блок может содержать только один объект Python фиксированного размера. Размер блока может варьироваться от 8 до 512 байт и должен быть кратен восьми. Для удобства такие блоки сгруппированы в 64 размерных класса.

Источник


Пул

Коллекция блоков одинакового размера называется пулом. Обычно размер пула равен размеру виртуальной страницы памяти, то есть 4КБ. Ограничение пула фиксированным размером блоков помогает при фрагментации. Если объект уничтожается, менеджер памяти может заполнить это пространство новым объектом того же размера.

У каждого пула заголовок следующий:

struct pool_header {
    union { block *_padding;
            uint count; } ref;          /* number of allocated blocks    */
    block *freeblock;                   /* pool's free list head         */
    struct pool_header *nextpool;       /* next pool of this size class  */
    struct pool_header *prevpool;       /* previous pool       ""        */
    uint arenaindex;                    /* index into arenas of base adr */
    uint szidx;                         /* block size class index        */
    uint nextoffset;                    /* bytes to virgin block         */
    uint maxnextoffset;                 /* largest valid nextoffset      */
};

Пулы блоков одинакового размера связаны друг с другом с помощью двусвязного списка (nextpool и prevpool). В поле szidx хранится индекс класса размера, а в ref.count - количество используемых блоков. The arenaindex хранит номер арены, в которой был создан Пул.

Поле freeblock описывается следующим образом:

Блоки внутри пулов вырезаются снова по мере необходимости. pool-> freeblock указывает на начало односвязного списка свободных блоков в пуле. Когда блок освобождается, он вставляется в начало списка свободных блоков его пула. Обратите внимание, что доступные блоки в пуле не связаны все вместе при инициализации пула.
Вместо этого устанавливаются только первые два блока (самые низкие адреса), возвращая первый блок и устанавливая указатель pool-> freeblock на одноблочный список, содержащий второй. Это согласуется с тем, что PyMalloc стремится на всех уровнях (арене, пуле и блоке) никогда не задействовать память, пока он действительно не понадобится.
Пока пул находится в используемом состоянии, мы уверены, что есть блок
доступный для размещения, а pool-> freeblock не NULL. Если pool-> freeblock указывает на конец списка свободных до того, как мы разбили весь пул на блоки, это означает, что мы просто еще не добрались до одного из блоков с более высоким адресом. Смещение от pool_header до начала «следующего» первичного блока сохраняется в элементе pool_header nextoffset, а наибольшее значение nextoffset, которое имеет смысл, сохраняется в элементе maxnextoffset при инициализации пула. Все блоки в пуле были переданы по крайней мере один раз, когда и только когда nextoffset > maxnextoffset.

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

Каждый пул имеет три состояния:

    • Used - частично используется; ни пустой, ни полный.
    • Full - все блоки пула в настоящее время распределены
    • Empty - все блоки пула в настоящее время доступны для распределения

Для эффективного управления пулами Python использует дополнительный массив с именем usedpools. Он хранит указатели на пулы, сгруппированные по классам. Как мы уже знаем, все пулы одинакового размера блока связаны между собой. Для итерации по ним, нам просто нужно знать начало списка. Если нет пулов такого размера, то при первом запросе памяти будет создан новый пул.


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


Арена

Арена представляет собой кусок памяти размером 256 КБ, выделенной в куче, которая обеспечивает память для 64 пулов.

Структура объекта арена:

struct arena_object {
    uintptr_t address;
    block* pool_address;
    uint nfreepools;
    uint ntotalpools;
    struct pool_header* freepools;
    struct arena_object* nextarena;
    struct arena_object* prevarena;
};

Все арены связаны с помощью двусвязного списка (nextarena и prevarena), это помогает управлять ими. ntotalpools и nfreepools хранят информацию о доступных в настоящее время пулах.

Поле freepools указывает на связанный список доступных пулов.

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


Напоминание для самых маленьких. Тут есть особенности представления объектов в памяти Python

Освобождение памяти

Менеджер памяти Python не обязательно освобождает память обратно в операционную систему, вместо этого память возвращается обратно интерпретатору Python.

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

Говоря о долго выполняющихся процессах Python, они могут содержать много неиспользуемой памяти из-за этого поведения.

Статистика выделения памяти

Можно получить статистику, вызвав sys._debugmalllocstats()

Алгоритмы сборки мусора

Стандартный сборщик мусора CPython состоит из двух компонентов:

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

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

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

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

Подсчет ссылок - это простой метод, при котором объекты освобождаются, когда в программе нет ссылок на них.

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

Каждый объект в Python наследуется от PyObject, который имеет поля:

    • счетчик ссылок
    • тип (указатель на тип)

Счётчик ссылок увеличивается/уменьшается при создании/удалении указателя на объект.

Примеры ситуаций, когда счётчик увеличится:

    • Оператор присваивания
    • Передача аргументов
    • Добавление объекта в список

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

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

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

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

Вы всегда можете проверить количество текущих ссылок, используя функцию sys.getrefcount().

import sys
foo = []
# 2 references, 1 from the foo var and 1 from getrefcount
print(sys.getrefcount(foo))

def bar(a):
    # 4 references
    # from the foo var, function argument, getrefcount and Python's function stack
    print(sys.getrefcount(a))
bar(foo)
# 2 references, the function scope is destroyed
print(sys.getrefcount(foo))

Алгоритм подсчета ссылок имеет много проблем, таких как циклические ссылки, блокировка потоков и память и накладные расходы производительности.

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

Generational garbage collector

Зачем нам нужен дополнительный сборщик мусора при подсчете ссылок?

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

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

Когда начинает работу GC?

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

    • GC классифицирует контейнерные объекты на три поколения.
    • Каждый новый объект начинается в первом поколении.
    • Если объект переживает раунд сбора мусора, он перемещается в старшее (на поколение выше).
    • Низшие поколения собирают чаще, чем высшие. Поскольку большинство вновь созданных объектов умирают молодыми, это повышает производительность GC и уменьшает время паузы GC.

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

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

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

Стандартные пороговые значения установлены на (700, 10, 10) соответственно, но вы всегда можете проверить их с помощью функции gc.get_threshold(). Вы также можете настроить их для своей конкретной рабочей нагрузки, используя функцию gc.get_threshold().

Как найти циклические ссылки?

Для того, чтобы понять, как работает этот алгоритм поиска, рекомендую прочитать это и это.

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

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

Если установить дебаг флаг в значение DEBUG_SAVEALL, все найденные недоступные объекты будут добавлены в список gc.garbage.

import gc

gc.set_debug(gc.DEBUG_SAVEALL)

print(gc.get_count())
lst = []
lst.append(lst)
list_id = id(lst)
del lst
gc.collect()
for item in gc.garbage:
    print(item)


Performance tips

  • Рецепт для отслеживания веса объектов
from tracemalloc import start, take_snapshot
class Resistor:
    def __init__(self, numbers, manufacturer, resistance):
        self.numbers = numbers
        self.manufacturer = manufacturer
        self.resistance = resistance
start()
before = take_snapshot()
r = Resistor('10-123-5445', 'honhai', 10)
after = take_snapshot()
for stat in (stat for stat in after.compare_to(before, 'lineno') if stat.traceback[0].filename == __file__):
    print(stat)
  • Каждый экземпляр объекта содержит dict названий и значений.
class Figure:
    pass
figure = Figure()
figure.name = 'Triangle'
figure.__dict__
{'name': 'Triangle'}

Использовать __slots__: они превращают внутренности объекта из dict в tuple.

class Point(object):
    __slots__ = ('x', 'y')
    
point = Point()
point.x = 5
point.y = 7
point.name = 'Fred'
Traceback (most recent call last):
  File "<input>", line 1, in <module>
AttributeError: 'Point' object has no attribute 'name'

Когда стоит использовать __slots__?
- Когда мы будем создавать много экземпляров класса
- Когда мы знаем заранее какие свойства этот класс должен иметь

  • Вам правда нужен список?
    Можно использовать генераторы, где возможно. Не всегда нужен доступ ко всем элементам списка. Где невозможно - tuple, плюс у кортежей bound checking есть встроенный.
def __iter__(self):
     return self._generator()
def _generator(self):
     for itm in self.items():
         yield itm
  • Использовать slicing с умом, лучше использовать view по возможности. Потому что на каждый слайс создается новый массив с копиями элементов из старого.
  • Добавить инфу про key-sharing dictionary PEP412
  • Добавить инфу про weakref и использование

TBD: more examples on performance with code