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

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

К концу этого урока вы:

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

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

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

Содержание

Память — это чистая книга   

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

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

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

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

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

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

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

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

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

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

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

Интерпретатор Python по умолчанию   

Интерпретатор Python по умолчанию, CPython, фактически написан на языке программирования C.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Счетчик ссылок используется для сборки мусора (garbage collection). Тогда у вас есть указатель на фактический тип объекта. Этот тип объекта — просто еще одна структура, описывающая объект Python (например, dict или int).Каждый объект имеет свой собственный объектно-зависимый распределитель памяти, который знает, как получить память для хранения этого объекта. У каждого объекта также есть объектно-зависимый механизм освобождения памяти, который «освобождает» память, когда она больше не нужна.

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

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

GIL (Global Interpreter Lock) — это решение общей проблемы работы с общими ресурсами, такими как память компьютера. Когда два потока пытаются одновременно изменить один и тот же ресурс, они могут наступить друг другу на пятки. Конечным результатом может быть запутанный беспорядок, когда ни один из потоков не получает то, что он хочет.

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

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

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

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

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

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

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

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

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

numbers = [1, 2, 3]
# Количество ссылок = 1
more_numbers = numbers
# Количество ссылок = 2

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

total = sum(numbers)

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

matrix = [numbers, numbers, numbers]

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

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

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

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

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

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

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

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

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

Комментарии в исходном коде описывают распределитель как “a fast, special-purpose memory allocator for small blocks, to be used on top of a general-purpose malloc.” (быстрый, специальный распределитель памяти для небольших блоков, который будет использоваться поверх универсального malloc). В данном случае malloc — это библиотечная функция языка C для выделения памяти.

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

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

Все блоки в данном пуле относятся к одному «классу размера». Класс размера определяет конкретный размер блока с учетом некоторого количества запрошенных данных.Приведенная ниже диаграмма взята непосредственно из комментариев к исходному коду:

Запрос в байтах Размер выделенного блока 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 байтов.

Пулы (Pools)   

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

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

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

Список свободных пулов отслеживает все пулы в пустом состоянии. Но когда используются пустые пулы?

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

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

Блокировка   

Как видно на диаграмме выше, пулы содержат указатель на свои «свободные» блоки памяти. В том, как это работает, есть небольшой нюанс. Согласно комментариям в исходном коде, этот распределитель «стремится на всех уровнях (арене, пуле и блоке) никогда не касаться части памяти, пока она действительно не понадобится».Это означает, что пул может иметь блоки в 3-х состояниях. Эти состояния можно определить следующим образом:

  • нетронута – часть памяти, которая не была выделена
  • свободна – часть памяти, которая была выделена, но позже освобождена CPython и больше не содержит актуальных и нужных в текущий момент данных.
  • распределена – часть памяти, которая фактически содержит нужные сейчас данные

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

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

Арены   

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

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

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

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

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

Заключение   

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

Из этом уроке вы узнали:

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

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

По мотивам Memory Management in Python

Опубликовано Вадим В. Костерин

ст. преп. кафедры ЦЭиИТ. Автор более 130 научных и учебно-методических работ. Лауреат ВДНХ (серебряная медаль).

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *