Помнить всё. Как работает память в Python

Kate

Administrator
Команда форума
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: арены состоят из пулов, пулы составлены из блоков
Схематическое представление структуры виртуально памяти Python: арены состоят из пулов, пулы составлены из блоков

Блок​

Блок содержит не более одного объекта 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():
>>> import sys
>>> sys._debugmallocstats()
Small block threshold = 512, in 32 size classes.
class size num pools blocks in use avail blocks
----- ---- --------- ------------- ------------
0 16 1 212 41
1 32 6 629 127
2 48 28 2317 35
3 64 118 7386 48
4 80 104 5164 36
5 96 17 696 18
6 112 11 372 24
7 128 7 212 5
8 144 37 1014 22
9 160 7 174 1
10 176 64 1426 46
11 192 4 66 18
12 208 3 42 15
13 224 11 181 17
14 240 5 65 15
15 256 3 35 10
16 272 2 23 5
17 288 3 28 14
18 304 15 184 11
19 320 2 17 7
20 336 2 14 10
21 352 2 13 9
22 368 2 15 7
23 384 1 9 1
24 400 3 23 7
25 416 4 31 5
26 432 6 42 12
27 448 4 28 8
28 464 4 26 6
29 480 4 27 5
30 496 5 33 7
31 512 6 37 5

# arenas allocated total = 10
# arenas reclaimed = 2
# arenas highwater mark = 8
# arenas allocated current = 8
8 arenas * 262144 bytes/arena = 2,097,152


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

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

Для освобождения памяти используются два механизма: счетчик ссылок и сборщик мусора.
Всё в Python является объектами, а прародителем всех типов объектов в реализации CPython является PyObject. От него наследуются все остальные типы. В PyObject определены счетчик ссылок и указатель на фактический тип объекта. Счетчик ссылок увеличивается на единицу, когда мы создаем что-то, что обращается к объекту, например, сохраняем объект в новой переменной. И наоборот, счетчик уменьшается на единицу, когда мы перестаем ссылаться на объект.
Счетчик ссылок любого объекта можно проверить с помощью sys.getrefcount(). Учтите, что передача объекта в getrefcount() увеличивает счетчик ссылок на 1, так как сам вызов метода создает еще одну ссылку. Когда счетчик уменьшается до нуля, происходит вызов аллокатора для освобождения соответствующих блоков памяти.
>>> import sys
>>> a = {}
>>> b = a
>>> sys.getrefcount(a)
3
>>> del b
>>> sys.getrefcount(a)
2


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

Заключение​

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

Источники​


Источник статьи: https://proglib.io/p/pomnit-vse-kak-rabotaet-pamyat-v-python-2021-03-14
 
Сверху