
Это третья часть серии из трех статей, посвященных различным аспектам управления памятью в Python. Это началось с выступления на конференции, которое я сделал в 2021 году, под названием Указатели? In My Python?, а самую последнюю его запись можно найти здесь.
Ознакомьтесь с Частью 1 и Частью 2 этой серии — или прочитайте обсуждение времени жизни объектов, подсчета ссылок и сборки мусора в CPython!
Как CPython может определить, когда вы закончили работу с объектом, и что происходит дальше
Мы закончили часть 2, задав вопросы: как только мы создали объект x, как и почему заканчивается его жизнь? В этой статье мы узнаем ответы, исследуя, как CPython освобождает объекты из памяти. CPython — не единственная реализация Python — например, есть Skulpt, который Anvil использует для запуска Python в браузере, — но именно на нем мы сосредоточимся специально для этой статьи.
Мы закончили часть 2 исследованием странных и замечательных вещей, которые могут произойти, когда вы переопределяете магический метод __eq__ в Python. Теперь, в части 3, мы собираемся сделать то же самое с помощью другого магического метода: __del__.
Магический метод __del__
Магический метод __del__, также называемый финализатором объекта, вызывается непосредственно перед удалением объекта из памяти. На самом деле он не выполняет работу по удалению объекта из памяти — мы увидим, как это происходит позже. Вместо этого этот метод предназначен для выполнения любой работы по очистке, которая должна произойти перед удалением объекта, например, для закрытия всех файлов, которые были открыты объектом при его создании.
В этом разделе мы будем использовать следующий класс в качестве примера:
class MyNamedClass: def __init__(self, name): self.name = namedef __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. Это делается следующим образом:
- Обнаруживает циклические изоляты
- Он вызывает финализаторы (методы
__del__) для каждого объекта в циклическом изолировании. - Он удаляет указатели из каждого объекта (тем самым прерывая цикл) — только если цикл все еще изолирован после шага 2 (подробнее об этом позже!)
После завершения этого процесса каждый объект, ранее находившийся в цикле, теперь будет иметь счетчик ссылок, равный 0, и, следовательно, будет удален из памяти.
Хотя он работает автоматически, мы можем импортировать его как модуль из стандартной библиотеки. Давайте сделаем это, чтобы мы могли подробно взглянуть на то, как это работает!
>>> import gc
Обнаружение циклических изолятов
Сборщик мусора CPython отслеживает различные объекты, существующие в памяти, но не все. Мы можем инстанцировать некоторые объекты и посмотреть, заботится ли о них сборщик мусора:
>>> gc.is_tracked("a string") False>>> gc.is_tracked(["a", "list"]) True
Если объект может содержать указатели, это дает ему возможность формировать часть циклической изолированной структуры — и именно это предназначено для обнаружения и разборки детектора мусора. Такие объекты в Python часто называют «объектами-контейнерами».
Таким образом, сборщик мусора должен знать о любом объекте, который потенциально может существовать как часть циклического изолирования. Строки не могут, поэтому сборщик мусора не отслеживает "a string". Списки (как мы видели) могут содержать указатели, поэтому ['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 (bob) и его friend (экземпляр MyNamedClass, на который также указывает jane). Объект bob также имеет указатель на сам объект класса, поскольку bob.__class__ вернет этот объект класса.
Когда сборщик мусора запускается, он проверяет, доступен ли из пространства имен каждый объект, о котором он знает (то есть все, что возвращает True, когда вы вызываете для него gc.is_tracked). Он делает это, следуя всем указателям из пространства имен и указателям внутри объектов, на которые указывают те, и так далее, пока не построит полное представление обо всем, что доступно из кода.
Если после этого сборщик мусора обнаружит, что существуют объекты, недоступные из пространства имен, то он может очистить эти объекты.
Помните, что любые объекты, которые все еще находятся в памяти, должны иметь ненулевой счетчик ссылок, иначе они были бы удалены из-за подсчета ссылок. Чтобы объекты были недоступны, но при этом имели ненулевой счетчик ссылок, они должны быть частью циклического изолирования, поэтому мы так заботимся о возможности их возникновения.
Вернемся к нашему циклу friends, jane и bob и превратим этот цикл в циклический изолят, удалив указатели из пространства имен:
>>> del jane
>>> del bob
Теперь мы попали именно в ту ситуацию, для исправления которой существует сборщик мусора. Мы можем запустить ручную сборку мусора, вызвав gc.collect():
>>> gc.collect()
Deleting Bob!
Deleting Jane!
4
По умолчанию сборщик мусора будет автоматически выполнять это действие время от времени (по мере того, как в среде выполнения CPython создается и уничтожается все больше и больше объектов).
Вывод, который мы видим в фрагменте кода выше, содержит операторы print из метода __del__ нашего MyNamedClass, а в конце есть число — в данном случае 4. Это число выводится из самого сборщика мусора, и он говорит нам сколько объектов было удалено.
Вы можете подумать, что были удалены только 2 объекта (наши два экземпляра MyNamedClass), но каждый из них также указывал на строковый объект (их name). Как только эти два экземпляра MyNamedClass удаляются, счетчик ссылок для каждой из этих строк name также падает до нуля, поэтому они тоже удаляются, в результате чего общее количество объектов достигает 4.
Финализаторы ведут себя плохо
Ранее мы упоминали, что сборщик мусора работает в трехэтапном процессе: обнаружение циклических изолятов, вызов финализаторов для каждого объекта в цикле, затем разрыв цикла путем удаления указателей между объектами... if цикл все еще остается изолированным в этой точке. Теперь единственный способ, которым цикл может перейти от изолированного к неизолированному между первым и третьим этапом, — это если финализаторы сделают что-то, чтобы это произошло.
Давайте определим класс, который делает именно это:
class MyBadClass: def __init__(self, name): self.name = namedef __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 полностью прерывает сборку мусора? Ответ отрицательный, и это из-за очень важного свойства финализаторов: их можно вызывать только один раз для каждого объекта. После того, как метод __del__ bob был вызван один раз (когда он был вызван вызовом 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!
Смотрите эту статью как обсуждение
Различные записанные версии этого выступления доступны по следующим ссылкам:
Подробнее об Анвиле
Если вы здесь впервые, добро пожаловать! Anvil — это платформа для создания полнофункциональных веб-приложений только на Python. Не нужно возиться с JS, HTML, CSS, Python, SQL и всеми их фреймворками — просто создайте все это на Python.