При использовании декоратора functools.lru_cache
для кэширования метода, экземпляры класса, инкапсулирующего этот метод, никогда не будут собираться сборщиком мусора Python в течение всего времени существования удерживающего их процесса.
Здесь создается простой класс SlowAdder
, который принимает значение delay
и перед вычислением суммы входных данных в методе .calculate
осуществляет задержку. Чтобы ускорить расчет для одних и тех же аргументов, метод .calculate
был обернут в декоратор @lru_cache
. Метод __del__
уведомляет, когда сборка мусора успешно очистила экземпляры класса.
# файл `test.py` import functools, time from typing import TypeVar Number = TypeVar("Number", int, float, complex) class SlowAdder: def __init__(self, delay: int = 1) -> None: self.delay = delay @functools.lru_cache # кэширование def calculate(self, *args: Number) -> Number: time.sleep(self.delay) return sum(args) def __del__(self) -> None: print("Удаление экземпляра ...") # Создаем экземпляр SlowAdder. slow_adder = SlowAdder(2) # Измерим производительность. start_time = time.perf_counter() # ---------------------------------------------- result = slow_adder.calculate(1, 2) # ---------------------------------------------- end_time = time.perf_counter() print(f"Расчет занял {end_time-start_time} сек., результат: {result}.") # Измерим производительность второго вызова. start_time = time.perf_counter() # ---------------------------------------------- result = slow_adder.calculate(1, 2) # ---------------------------------------------- end_time = time.perf_counter() print(f"Расчет занял {end_time-start_time} сек., результат: {result}.") # Вывод: # Расчет занял 2.002337301999887 сек., результат: 3. # Расчет занял 7.445999926858349e-06 сек., результат: 3. # Удаление экземпляра ...
Можете видеть, что декоратор @lru_cache
успешно выполнил свою работу. Второй вызов метода .calculate()
с тем же аргументом занял заметно меньше времени по сравнению с первым. Во втором случае декоратор lru_cache
просто выполняет простой поиск по словарю. Все это хорошо, но экземпляры класса ShowAdder()
не собираются сборщиком мусора в течение всего срока службы программы. Докажем это.
Если если запустить приведенный выше код в интерактивном режиме (с флагом -i
), то можно доказать, что сборка мусора не происходит.
# $ python -i src.py # Расчет занял 2.0019949389998146 сек., результат: 3. # Расчет занял 7.333000212383922e-06 сек., результат: 3. >>> import gc >>> slow_adder.calculate(1,2) # 3 # Присвоим значение `None` >>> slow_adder = None # вручную запустим сборщик мусора >>> gc.collect() # 0
Здесь slow_adder
присвоено значение None
, а затем явно запущен сборщик мусора gc.collect()
. Однако метод __del__
не выводит сообщение об удалении экземпляра, а результат gc.collect()
равен 0. Это означает, что кто-то держит ссылку на экземпляр slow_adder
. Попробуем узнать, кто же держит эту ссылку:
# $ python -i src.py # Расчет занял 2.0019949389998146 сек., результат: 3. # Расчет занял 7.333000212383922e-06 сек., результат: 3. >>> slow_adder.calculate.cache_info() # CacheInfo(hits=1, misses=1, maxsize=128, currsize=1) # очистим кэш >>> slow_adder.calculate.cache_clear() # Присвоим значение `None` >>> slow_adder = None # Удаление экземпляра ...
Метод .cache_info()
показывает, что контейнер кэша сохраняет ссылку на экземпляр до тех пор, пока он не будет очищен. Когда кэш очищается вручную и назначается slow_adder = None
, только тогда сборщик мусора удалил экземпляр. По умолчанию размер lru_cache
равен 128, а если бы lru_cache(maxsize=None)
... Это может быть опасно, если создаются миллионы экземпляров, и они не собираются сборщиком мусора Python естественным образом. Такое поведения может переполнить рабочую память и привести к сбою процесса!
Чтобы решить эту проблему, необходимо сделать контейнеры кэша для экземпляров локальными, чтобы ссылка из кэша на экземпляр удалялась вместе с экземпляром. Вот как это сделать:
# файл test2.py import functools, time from typing import TypeVar Number = TypeVar("Number", int, float, complex) class SlowAdder: def __init__(self, delay: int = 1) -> None: self.delay = delay # кэширование self.calculate = functools.lru_cache()(self._calculate) def _calculate(self, *args: Number) -> Number: time.sleep(self.delay) return sum(args) def __del__(self) -> None: print("Удаление экземпляра ...")
Единственное отличие здесь заключается в том, что вместо непосредственного декорирования метода, декоратор functools.lru_cache()
вызывается как обычная функция c методом _calculate
, а кэшированный результат сохраняется как переменная экземпляра с именем calculate
. Экземпляры этого класса будут уничтожаться сборщиком мусора, как обычно.
НО после использования этого решения, происходит странная вещь. Запустим код в интерактивном режиме:
# $ python -i src_2.py >>> slow_adder = SlowAdder(2) >>> slow_adder.calculate(1,2) # 3 >>> slow_adder # <__main__.SlowAdder object at 0x7f0e91adde50> >>> slow_adder1 = SlowAdder(2) # экземпляр `slow_adder1` не берет кэшированный # результат, а снова его рассчитывает! Почему? >>> slow_adder1.calculate(1,2) # 3
Декоратор .lru_cache
использует словарь для кэширования вычисленных значений. Для создания ключа словаря, хэш-функция применяется ко всем аргументам целевого метода. Это означает, что первый аргумент self
также включается при построении ключа кэша. Для разных экземпляров аргумент self
будет другим, что делает ключ кэша различным для каждого экземпляра. Как же быть?
Методы класса и статические методы не страдают от вышеуказанными проблемами, т.к. они не имеют никаких связей со своими экземплярами. В их случае контейнер кэша является локальным для класса, а не для экземпляров. Здесь можно разместить декоратор @lru_cache
, как обычно.
# файл test3.py import functools import time class Foo: @classmethod @functools.lru_cache def bar(cls, delay: int) -> int: cls.delay = delay time.sleep(delay) return 42 def __del__(self) -> None: print("Удаление экземпляра ...")
Запустим тест в интерактивном режиме:
# $ python3 -i test.py >>> foo = Foo() >>> result = foo.bar(2) >>> print(result) # 42 >>> foo1 = Foo() >>> result = foo1.bar(2) >>> print(result) # 42 >>> foo1 = None # Удаление экземпляра ...
Статические методы ведут себя точно так же:
import functools, time class Foo: @staticmethod @functools.lru_cache def bar(delay: int) -> int: return 42 def __del__(self) -> None: print("Удаление экземпляра ...")