Всем привет!
В этой статье будет рассмотрено управление деструкцией объектов в питоне, а также архитектура с кросс-ссылками объектов таким образом, чтобы она не приводила к утечкам памяти.
Сперва рекомендую настроить окружение для экспериментов. Нужно открыть терминал (н-р в Ubuntu) и выполнить следующие команды:
$ sudo apt-get install python-dev python3-dev
$ virtualenv .v2 -p python2.7
$ . .v2/bin/activate
$ pip install psutil memory-profiler
и для 3-го питона:
$ virtualenv .v3 -p python3
$ . .v3/bin/activate
$ pip install psutil memory-profiler
В питоне имеется сборщик мусора, который удаляет объект из памяти (освобождает память под новые объекты), если на объект не ссылается ни одна переменная. Н-р следующий код:
class A(object):
def __del__(self):
print("delete")
def main():
a = A()
print("end")
if __name__ == "__main__":
main()
В случае, если его сохранить и выполнить, будет получен результат:
$ python mem.py
end
delete
Метод __del__
вызывается у объекта, когда его уничтожает сборщик мусора. В данном случае это произошло в момент выхода из функции, когда переменная a
перестала существовать и на инстанциированный объект больше никто не ссылался.
Модифицировав код:
class A(object):
def __del__(self):
print("delete")
def main():
a = A()
del a
print("end")
if __name__ == "__main__":
main()
Будет получен другой результат:
$ python mem.py
delete
end
Инструкция del
удаляет переменную, что приводит к обнулению ссылок на объект и его удалению сборщиком мусора.
Если два объекта будут через свои атрибуты ссылаться друг на друга, то сборщик мусора их не станет удалять, даже если ни одна переменная в исполняемом коде на эти объекты не ссылается. В таком случае говорят про “утечку” памяти (в python3 сборщик мусора все-таки их удаляет, но это не спасает от утечки тем не менее).
Подобных циклических ссылок стараются избегать при построении архитектуры приложения. В целом принято разбивать код на уровни абстракции с односторонней направленностью, когда более низкий уровень ничего не знает про более высокий, а более высокий использует более низкий для каких-либо действий.
Однако в случае списков, деревьев случается полезным организовать двунаправленную связь, когда не только родитель знает о своих потомках, но и потомок может обратиться к своему родителю. Это актуально при динамических вычислениях, когда на вызов метода потомка влияет значение, полученное от родителя. Н-р модифицировав предыдущий код:
class A(object):
def __init__(self, name, parent=None):
self.name = name
self.parent = parent
self.children = set()
def __del__(self):
print("delete", self.name)
def main():
a = A(name=1)
a.children.add(A(name=2, parent=a))
print("end")
if __name__ == "__main__":
main()
Результат будет:
$ python mem.py
end
Объекты не были удалены при завершении, т.к. ссылались друг на друга. В случае если количество циклических ссылок будет расти в коде, это приведет к росту потребленной памяти, пока она не кончится. Н-р:
from memory_profiler import profile
class A(object):
def __init__(self, name, parent=None):
self.name = name
self.parent = parent
self.children = set()
self.workload = ' ' * 128 * 1024 * 1024
def __del__(self):
print("delete", self.name)
@profile
def main():
for _ in range(10):
a = A(name=1)
a.children.add(A(name=2, parent=a))
print("end")
if __name__ == "__main__":
main()
Затребует памяти около 2.5Gb:
$ python -m memory_profiler mem.py
end
Filename: mem.py
Line # Mem usage Increment Line Contents
================================================
16 13.2 MiB 0.0 MiB @profile
17 def main():
18 2569.4 MiB 2556.2 MiB for _ in range(10):
19 2441.4 MiB -128.0 MiB a = A(name=1)
20 2569.4 MiB 128.0 MiB a.children.add(A(name=2, parent=a))
21 2569.6 MiB 0.2 MiB print("end")
При этом удаление объектов не произошло, а память осводилась средствами операционной системы - процесс питона завершился. Как говорилось выше, python3 умеет распознавать объекты, которые указывают друг на друга, и удалять их при завершении, однако в момент вычислений память по-прежнему будет “течь”. Вышеописанный скрипт при запуске python3:
$ python -m memory_profiler mem.py
end
Filename: mem.py
Line # Mem usage Increment Line Contents
================================================
16 13.2 MiB 0.0 MiB @profile
17 def main():
18 2573.1 MiB 2559.9 MiB for _ in range(10):
19 2445.3 MiB -127.7 MiB a = A(name=1)
20 2573.1 MiB 127.7 MiB a.children.add(A(name=2, parent=a))
21 2573.1 MiB 0.0 MiB print("end")
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
Видно, что объекты были удалены сборщиком мусора, вызвав метод __del__
.
Чтобы избежать утечек памяти, нужно использовать “слабые” ссылки (модуль weakref
). “Слабые” ссылки не влияют на подсчет ссылок сборщиком мусора. При этом правильно использовать “слабые” ссылки тогда, когда потомок ссылается на родителя. Это приведет к тому, что при обнулении ссылок н-р на корень дерева, вместе с корнем каскадно будут удалены все потомки, при условии, что на них не ссылается внешняя переменная. По подобной каскадной модели работает освобождение памяти в Qt
.
import weakref
from memory_profiler import profile
class A(object):
def __init__(self, name, parent=None):
self.name = name
self._parent = weakref.ref(parent) if parent else parent
self.children = set()
self.workload = ' ' * 128 * 1024 * 1024
@property
def parent(self):
if not self._parent:
return self._parent
_parent = self._parent()
if _parent:
return _parent
else:
raise LookupError("Parent was destroyed")
def __del__(self):
print("delete", self.name)
@profile
def main():
for _ in range(10):
a = A(name=1)
a.children.add(A(name=2, parent=a))
print("end")
if __name__ == "__main__":
main()
Потребление памяти резко снизилось и будет сохраняться константным даже в случае увеличения количества циклов:
$ python -m memory_profiler mem.py
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
delete 1
delete 2
end
delete 1
delete 2
Filename: mem.py
Line # Mem usage Increment Line Contents
================================================
28 13.0 MiB 0.0 MiB @profile
29 def main():
30 269.2 MiB 256.1 MiB for _ in range(10):
31 141.3 MiB -127.9 MiB a = A(name=1)
32 269.2 MiB 127.9 MiB a.children.add(A(name=2, parent=a))
33 269.1 MiB -0.1 MiB print("end")
Как видно сборка мусора происходит в каждой итерации в момент переприсвоения переменной a
. Почему происходит сборка мусора после вывода end
, также легко догадаться.
Глядя на метод __del__
, может возникнуть искушение использовать его для различных завершающих операций, н-р закрытие канала, сокета, файла, остановка сервера, прокси и т.п. Однако никогда не нужно в методе __del__
использовать вызовы, потенциально приводящие к исключениям. Если какой-либо метод объекта порождает исключение, интерпретатор питона сохраняет ссылку на этот объект в трейсбэке для последующего вывода места ошибки. Таким образом объект будет существовать до самого завершения процесса. Н-р код:
class A(object):
def method(self):
pass
def __del__(self):
print("delete")
def main():
a = A()
try:
a.method()
finally:
del a
print("end")
if __name__ == "__main__":
main()
Даст результат:
$ python mem.py
delete
end
Но в случае исключения:
class A(object):
def method(self):
raise Exception()
def __del__(self):
print("delete")
def main():
a = A()
try:
a.method()
finally:
del a
print("end")
if __name__ == "__main__":
main()
Деструкция объекта произойдет в последнюю очередь:
$ python mem.py
end
Traceback (most recent call last):
File "mem.py", line 20, in <module>
main()
File "mem.py", line 13, in main
a.method()
File "mem.py", line 4, in method
raise Exception()
Exception
delete
Стоит однозначно запомнить, что del obj
не означает, что тут же произойдет вызов obj.__del__()
. Инструкция del
лишь уменьшает количество ссылок на объект на 1. Метод же __del__()
будет вызван лишь когда количество ссылок будет 0.
Спасибо за внимание