Указатели? В моем Python? Это более вероятно, чем вы думаете — часть 3: Время жизни объектов и сборка мусора

Это третья часть серии из трех частей, в которой рассматриваются различные аспекты управления памятью в Python. Она началась как доклад на конференции, который я сделал в 2021 году под названием «Указатели? В моем Python?», а последнюю запись этого выступления можно найти здесь.

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

Как CPython может определить, когда вы закончили работу с объектом, и что происходит дальше

В конце второй части мы задались вопросом: как только мы создали объект x, как и почему заканчивается его «время жизни»? В этой статье мы узнаем ответы, изучив, как CPython освобождает объекты из памяти. CPython не является единственной реализацией Python — например, есть Skulpt, который Anvil использует для запуска Python в браузере — но именно на нем мы сосредоточимся в этой статье.

Мы закончили часть 2 исследованием странных и удивительных вещей, которые могут произойти, когда вы переопределяете магический метод __eq__ в Python. Теперь, в третьей части, мы рассмотрим, как сделать то же самое с помощью другого магического метода: __del__.

Магический метод __del__

Магический метод __del__, также называемый финализатором объекта, — это метод, который вызывается непосредственно перед тем, как объект будет удален из памяти. На самом деле он не выполняет работу по удалению объекта из памяти — мы увидим, как это происходит позже. Вместо этого, этот метод предназначен для выполнения любой работы по очистке, которая должна произойти перед удалением объекта — например, закрытие всех файлов, которые были открыты объектом при его создании.

В этом разделе мы будем использовать следующий класс в качестве примера:

class MyNamedClass:
  def __init__(self, name):
    self.name = name

  def __del__(self):
    print(f"Deleting {self.name}!")
Вход в полноэкранный режим Выход из полноэкранного режима

Это просто класс, который сообщит нам, когда один из его экземпляров будет удален из памяти — или, более конкретно, когда Python ожидает немедленного удаления экземпляра класса из памяти (это не всегда так, как мы увидим!).

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

Итак, когда CPython решит удалить объект из памяти? Это происходит (начиная с версии CPython 3.10) двумя способами: Подсчет ссылок и сборка мусора.

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

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

>>> jane = MyNamedClass("Jane")
>>> del jane
Deleting Jane!
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы создаем новый объект (MyNamedClass("Jane")) и создаем указатель, указывающий на него (jane =). Затем, когда мы del jane, мы удаляем эту ссылку, и экземпляр MyNamedClass теперь имеет счетчик ссылок 0. Поэтому CPython решает удалить его из памяти — и прямо перед этим вызывается его метод __del__, который выводит сообщение, показанное выше.

Если мы создадим несколько ссылок на объект, нам придется избавиться от всех из них, чтобы объект был удален:

>>> bob = MyNamedClass("Bob")
>>> bob_two = bob # creating a new pointer to the same object
>>> del bob # this doesn't cause the object to be removed...
>>> del bob_two # ... but this does
Deleting Bob!
Войти в полноэкранный режим Выйти из полноэкранного режима

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

>>> jane = MyNamedClass("Jane")
>>> bob = MyNamedClass("Bob")
>>> jane.friend = bob # now the "Jane" object contains a pointer to the "Bob" object...
>>> bob.friend = jane # ... and vice versa
Вход в полноэкранный режим Выход из полноэкранного режима

В приведенном выше фрагменте кода мы установили несколько циклических ссылок. Объект, name которого Jane, содержит указатель на объект, name которого Bob, и наоборот. Это становится интересным, когда мы делаем следующее:

>>> del jane
>>> del bob
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь мы удалили указатели, которые идут от пространства имен к объектам. Теперь мы не можем получить доступ к этим объектам MyNamedClass — но мы не получили сообщения print о том, что они будут удалены. Это происходит потому, что на эти объекты все еще есть ссылки, содержащиеся друг в друге, и поэтому их количество ссылок не равно 0.

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

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

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

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

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

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

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

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

Сборщик мусора предназначен для поиска и удаления циклических изолятов из рабочей памяти CPython. Он делает это следующим образом:

  1. Он обнаруживает циклические изоляты
  2. Он вызывает финализаторы (методы __del__) для каждого объекта в циклической изоляции
  3. Удаляет указатели из каждого объекта (тем самым разрывая цикл) — только если цикл все еще изолирован после шага 2 (подробнее об этом позже!).

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

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

>>> import gc
Вход в полноэкранный режим Выход из полноэкранного режима

Обнаружение циклических изоляторов

Сборщик мусора CPython отслеживает различные объекты, существующие в памяти — но не все. Мы можем инстанцировать некоторые объекты и посмотреть, заботится ли о них сборщик мусора:

>>> gc.is_tracked("a string")
False

>>> gc.is_tracked(["a", "list"])
True
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Итак, сборщику мусора необходимо знать о любом объекте, который потенциально может существовать как часть циклической изолированной структуры. Строки не могут, поэтому "строка" не отслеживается сборщиком мусора. Списки (как мы видели) могут содержать указатели, и поэтому ['a', 'list'] отслеживается.

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

>>> jane = MyNamedClass("Jane")
>>> gc.is_tracked(jane)
True
Вход в полноэкранный режим Выход из полноэкранного режима

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

>>> my_list = [“a”, “list”]
>>> gc.get_referents(my_list)
[‘list’, ‘a’]
Войти в полноэкранный режим Выход из полноэкранного режима

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

Давайте рассмотрим метод get_referents в контексте цикла объектов (хотя это еще не циклический изолят, поскольку к этим объектам все еще можно получить доступ из пространства имен):

>>> jane = MyNamedClass("Jane")
>>> bob = MyNamedClass("Bob")
>>> jane.friend = bob
>>> bob.friend = jane
>>> gc.get_referents(bob)
[{'name': 'bob', 'friend': <__main__.MyNamedClass object at 0x7ff29a095d60>}, <class '__main__.MyNamedClass'>]
Вход в полноэкранный режим Выход из полноэкранного режима

В этом цикле мы видим, что объект, на который указывает bob, содержит указатели на следующее: словарь его атрибутов, содержащий bob‘s name (bob) и его friend (экземпляр MyNamedClass, на который также указывает jane). Объект bob также имеет указатель на сам объект класса, поскольку bob.__class__ возвращает этот объект класса.

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

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

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

Давайте вернемся к нашему циклу из friends, jane и bob, и превратим этот цикл в циклический изолят, удалив указатели из пространства имен:

>>> del jane
>>> del bob
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь мы попали именно в ту ситуацию, для исправления которой существует сборщик мусора. Мы можем запустить ручную сборку мусора, вызвав gc.collect():

>>> gc.collect()
Deleting Bob!
Deleting Jane!
4
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Вывод, который мы видим в приведенном фрагменте кода, содержит утверждения print из метода MyNamedClass нашего __del__, а в конце стоит число — в данном случае 4. Это число выводит сам сборщик мусора, и оно говорит нам, сколько объектов было удалено.

Вы можете подумать, что было удалено только 2 объекта (два экземпляра MyNamedClass), но каждый из них также указывал на строковый объект (их name). После удаления этих двух экземпляров MyNamedClass количество ссылок для каждой из этих строк name также упадет до нуля, поэтому они тоже будут удалены, в результате чего общее количество объектов достигнет 4.

Финализаторы ведут себя плохо

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

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

class MyBadClass:
  def __init__(self, name):
    self.name = name

  def __del__(self):
    global person # create an externally accessible pointer...
    person = self # ... and point it at the object about to be removed
    print(f“deleting {self.name}!”)
Вход в полноэкранный режим Выход из полноэкранного режима

В финализаторе этого класса создается глобальная переменная. Это означает, что даже если экземпляр MyBadClass станет недоступным из пространства имен (например, как часть циклической изоляции), он все равно сможет «достучаться» в пространство имен, создать там указатель и указать этим указателем на себя — таким образом, де-изолировав себя.

>>> jane = MyBadClass("Jane")
>>> bob = MyBadClass("Bob")
>>> jane.friend = bob
>>> bob.friend = jane
>>> del jane
>>> del bob
Вход в полноэкранный режим Выход из полноэкранного режима

Чтобы увидеть это в действии, мы создаем циклическую структуру изоляции, как мы это уже делали с другими (более хорошо управляемыми) классами. Затем мы запускаем сборку мусора:

>>> gc.collect()
Deleting Bob!
Deleting Jane!
0
Вход в полноэкранный режим Выход из полноэкранного режима

Мы видим сообщения печати из метода __del__ каждого экземпляра, но после этого сборщик мусора выводит нам 0. Это означает, что никакие объекты не были удалены из памяти — и это потому, что после того, как сборщик мусора вызвал финализаторы, он проверил, что цикл все еще изолирован.

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

Итак, если мы избавились от указателей jane и bob, как можно по-прежнему обращаться к циклу из пространства имен? Ответ заключается в переменной global person, которая была создана в финализаторе. Давайте посмотрим на нее:

>>> person
<__main__.MyNamedClass object at 0x7ff29a095d60>

>>> person.name
'Jane'

>>> person.friend.name
'Bob'
Вход в полноэкранный режим Выход из полноэкранного режима

Мы видим, что объект, на который указывает person — это тот же объект, на который ранее указывала jane. Это имеет смысл, если вы посмотрите на приведенный выше результат вызова gc.collect(); оператор print, который появился последним, был для объекта Jane, и, следовательно, это был объект, который установил person = self совсем недавно.

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

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

Итак, нарушает ли MyBadClass сборку мусора полностью? Ответ — нет, и это из-за очень важного свойства финализаторов: они могут быть вызваны только один раз для каждого объекта. После того, как метод bob __del__ был вызван один раз (когда он был вызван вызовом gc.collect()), он завершен и больше никогда не может быть выполнен. Это означает, что мы можем сделать следующее:

>>> del person
>>> gc.collect()
4
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы не видим сообщений "Deleting Jane" и "Deleting Bob!", потому что они выводятся финализаторами объектов, а они уже были вызваны один раз и не могут быть вызваны снова. Но поскольку указатель person был удален, цикл снова изолирован; и поскольку этот указатель person не будет воссоздан финализатором, сборщик мусора может спокойно идти дальше и удалять указатели, связывающие наши два экземпляра MyBadClass.

Затем сборщик мусора продолжает свою работу, печатая 4, чтобы сообщить нам, что эти два объекта и их атрибуты name были удалены из памяти — и все в мире (по крайней мере, в нашем интерпретаторе CPython!) снова хорошо!

Итак, чему мы научились?

Давайте подведем итоги! Эти три статьи были обзорным туром о том, как Python работает с объектами в памяти. Мы рассмотрели, как работают указатели, почему происходит выравнивание указателей и нужно ли использовать =, copy или deepcopy. Мы также увидели, что такое идентификаторы объектов, что компаратор is использует их, и как мы можем переопределить магический метод __eq__ для определения собственных условий равенства, чтобы заставить == делать все, что мы хотим. Наконец, мы рассмотрели время жизни объектов, магический метод __del__ и то, как CPython освобождает объекты из памяти, когда они больше не нужны, используя подсчет ссылок и сборку мусора.

Теперь, когда вы знаете все эти вещи, вы можете идти и писать лучший код на Python!

Смотрите эту статью в виде доклада

Различные записанные версии этого выступления доступны по следующим ссылкам:

  • EuroPython 2021
  • PyCon India 2021
  • Виртуальная встреча PyBerlin сентябрь 2021 г.

Больше об Anvil

Если вы здесь недавно, добро пожаловать! Anvil — это платформа для создания полнофункциональных веб-приложений с помощью всего лишь Python. Нет необходимости возиться с JS, HTML, CSS, Python, SQL и всеми их фреймворками — просто создайте все это на Python.

Попробуйте Anvil — это бесплатно и навсегда.

Оцените статью
devanswers.ru
Добавить комментарий