Циклические ссылки без утечек памяти и деструкция объектов в python'e

Всем привет!

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

Сперва рекомендую настроить окружение для экспериментов. Нужно открыть терминал (н-р в 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.

Спасибо за внимание

5 лайков

В кои то века по-настоящему интересная статья, а не мыльная опера.

1 лайк

благодарю)