Сообщить об ошибке.

Особенности кэширование методов экземпляра lru_cache в Python

При использовании декоратора 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("Удаление экземпляра ...")