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

Оглавление

Вы когда-нибудь задумывались о том, как Python обрабатывает ваши данные за кулисами? Как ваши переменные хранятся в памяти? Когда они удаляются?

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

К концу этой статьи вы:

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

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

"Память - пустая книга"

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

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

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

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

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

Управление памятью: От аппаратного к программному обеспечению

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

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

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

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

Поверх ОС существуют приложения, одним из которых является стандартная реализация Python (включенная в вашу ОС или загруженная с сайта python.org). Управление памятью для вашего кода Python осуществляется приложением Python. Алгоритмы и структуры, которые приложение Python использует для управления памятью, являются основной темой этой статьи.

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

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

Когда я впервые услышал эту песню, она взорвала мой мозг. Язык, который написан на другом языке?! Ну, не совсем, но вроде того.

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

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

Примечание: Виртуальные машины похожи на физические компьютеры, но реализованы программно. Они обычно обрабатывают базовые инструкции, похожие на инструкции Assembly.

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

Вы когда-нибудь видели .pyc файл или __pycache__ папку? Это байткод, который интерпретируется виртуальной машиной.

Важно отметить, что существуют и другие реализации, кроме CPython. IronPython компилируется для запуска на Microsoft's Common Language Runtime. Jython компилируется в Java байткод для запуска на виртуальной машине Java. Есть еще PyPy, но он заслуживает собственной целой статьи, поэтому я упомяну о нем лишь вскользь.

В рамках этой статьи я сосредоточусь на управлении памятью, которое осуществляется стандартной реализацией Python, CPython.

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

Итак, CPython написан на C, и он интерпретирует байткод Python. Какое отношение это имеет к управлению памятью? Ну, алгоритмы и структуры управления памятью существуют в коде CPython, на языке C. Чтобы понять управление памятью в Python, вам нужно получить базовое представление о самом CPython.

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

Возможно, вы слышали, что в Python все является объектом, даже такие типы, как int и str. Так вот, в CPython это верно на уровне реализации. Существует struct, называемый PyObject, который использует каждый другой объект в CPython.

Примечание: struct, или структура, в языке C - это пользовательский тип данных, который объединяет различные типы данных. Если сравнивать с объектно-ориентированными языками, то это как класс с атрибутами и без методов.

Объект PyObject, дедушка всех объектов в Python, содержит только две вещи:

  • ob_refcnt: счетчик ссылок
  • ob_type: указатель на другой тип

Счетчик ссылок используется для сборки мусора. Затем у вас есть указатель на реальный тип объекта. Этот тип объекта - просто еще один struct, описывающий объект Python (например, dict или int).

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

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

Глобальная блокировка интерпретатора (GIL)

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

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

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

Одним из решений этой проблемы является единая, глобальная блокировка интерпретатора, когда поток взаимодействует с общим ресурсом (страницей в книге). Другими словами, только один автор может писать одновременно.

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

У этого подхода есть свои плюсы и минусы, и GIL активно обсуждается в сообществе Python. Чтобы прочитать больше о GIL, я рекомендую ознакомиться с Что такое глобальная блокировка интерпретатора Python (GIL)?.

Сборка мусора

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

Эту старую запись без ссылок можно сравнить с объектом в Python, количество ссылок на который сократилось до 0. Помните, что каждый объект в Python имеет счетчик ссылок и указатель на тип.

Количество ссылок увеличивается по нескольким разным причинам. Например, количество ссылок увеличится, если вы присвоите ее другой переменной:

Python

numbers = [1, 2, 3]
# Reference count = 1
more_numbers = numbers
# Reference count = 2

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

Python

total = sum(numbers)

В качестве последнего примера, количество ссылок увеличится, если вы включите объект в список:

Python

matrix = [numbers, numbers, numbers]

Python позволяет проверить текущее количество ссылок на объект с помощью модуля sys. Вы можете использовать sys.getrefcount(numbers), но имейте в виду, что передача объекта в getrefcount() увеличивает количество ссылок на 1.

В любом случае, если объект все еще должен присутствовать в вашем коде, количество его ссылок больше, чем 0. Как только он уменьшается до 0, у объекта есть специальная функция deallocation, которая вызывается и "освобождает" память, чтобы другие объекты могли ее использовать.

Но что значит "освободить" память, и как другие объекты используют ее? Давайте сразу перейдем к управлению памятью в CPython.

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

Мы собираемся глубоко погрузиться в архитектуру памяти и алгоритмы CPython, так что пристегнитесь.

Как уже упоминалось, существуют уровни абстракции от физического оборудования до CPython. Операционная система (ОС) абстрагирует физическую память и создает слой виртуальной памяти, к которому могут обращаться приложения (включая Python).

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

Blocks to Show Different Areas of Memory with Object Memory Highlighted

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

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

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

В комментариях к исходному коду исходный код описывает аллокатор как "быстрый, специализированный аллокатор памяти для небольших блоков, используемый поверх malloc общего назначения". В данном случае malloc - это библиотечная функция языка Си для выделения памяти.

Теперь мы рассмотрим стратегию распределения памяти в CPython. Сначала мы поговорим о 3 основных элементах и о том, как они связаны друг с другом.

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

Book with Page filled with Arena, Pools, and Block

Внутри арен находятся пулы, которые представляют собой одну страницу виртуальной памяти (4 килобайта). Это как страницы в нашей книжной аналогии. Эти пулы фрагментируются на более мелкие блоки памяти.

Все блоки в данном пуле имеют один и тот же "класс размера". Размерный класс определяет конкретный размер блока при определенном объеме запрашиваемых данных. Диаграмма ниже взята непосредственно из исходного кода с комментариями:

Request in bytes Size of allocated block Size class idx
1-8 8 0
9-16 16 1
17-24 24 2
25-32 32 3
33-40 40 4
41-48 48 5
49-56 56 6
57-64 64 7
65-72 72 8
497-504 504 62
505-512 512 63

Например, если запрошено 42 байта, данные будут помещены в блок размером 48 байт.

Пулы

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

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

Сами пулы должны находиться в одном из 3 состояний: used, full или empty. Пул used имеет свободные блоки для хранения данных. В пуле full все блоки выделены и содержат данные. В пуле empty данные не хранятся, и при необходимости блокам может быть назначен любой класс размера.

В списке freepools хранятся все пулы, находящиеся в состоянии empty. Но когда пустые пулы начинают использоваться?

Предположим, что вашему коду нужен 8-байтовый кусок памяти. Если в usedpools нет пулов класса 8-байтового размера, инициализируется новый пул empty для хранения 8-байтовых блоков. Затем этот новый пул добавляется в список usedpools, чтобы его можно было использовать для будущих запросов.

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

Теперь вы видите, как пулы могут свободно перемещаться между этими состояниями (и даже классами объема памяти) с помощью этого алгоритма.

Блоки

Diagram of Used, Full, and Emtpy Pools

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

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

  • untouched: часть памяти, которая не была выделена
  • free: часть памяти, которая была выделена, но позже была "освобождена" CPython и больше не содержит соответствующих данных
  • allocated: часть памяти, которая действительно содержит соответствующие данные

Указатель freeblock указывает на односвязный список свободных блоков памяти. Другими словами, на список доступных мест для размещения данных. Если требуется больше свободных блоков, чем есть в наличии, аллокатор получит несколько untouched блоков из пула.

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

Diagrams showing freeblock Singly-Linked List Pointing to Free Blocks in a Pool

Arenas

Arenas содержат пулы. Эти пулы могут быть used, full или empty. Сами арены не имеют столь явных состояний, как пулы.

Вместо этого

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

usable_areas Doubly-linked List of Arenas

Это означает, что для размещения новых данных будет выбрана та арена, которая наиболее заполнена данными. Но почему бы не сделать наоборот? Почему бы не размещать данные там, где больше всего свободного места?

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

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

Заключение

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

В этой статье вы узнали:

  • Что такое управление памятью и почему оно важно
  • Как стандартная реализация Python, CPython, написана на языке программирования C
  • Как структуры данных и алгоритмы работают вместе в управлении памятью CPython для обработки ваших данных

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

Вернуться на верх