Локальный LRU-кэш

Разделяемое состояние, если оно обладает признаками изменяемости, должно настоятельно нуждаться в ошибке, такова общепризнанная истина.

— с извинениями к Джейн Остин

Как учили нас г-жа Остин и Хенрик Айхенхардт, разделяемое изменяемое состояние является корнем всего зла. Тем не менее, официальная документация functools предлагает нам писать код наподобие следующего…

@lru_cache(maxsize=32)
def get_pep(num):
    'Retrieve text of a Python Enhancement Proposal'
    resource = 'http://www.python.org/dev/peps/pep-%04d/' % num
    try:
        with urllib.request.urlopen(resource) as s:
            return s.read()
    except urllib.error.HTTPError:
        return 'Not Found'

(Код дословно скопирован из официальной документации.)

Декоратор @lru_cache(maxsize=32) теперь… глобальное изменяемое состояние модуля. Он больше не разделяется в Python, по сравнению с глобальным модулем: каждый импорт модуля будет разделять объект!

Мы пытаемся сделать вид, что нет «семантической» разницы: кэш — это «просто» оптимизация. Однако все очень быстро начинает рушиться: в конце концов, почему даже в документации говорится, как получить исходную функцию (ответ: .__wrapped__), если кэш такой безопасный?

Нет, обернуть функцию декоратором lru_cache совсем непросто! Во-первых, поскольку речь идет об изменяемом состоянии разделяемого потока, мы ввели некоторую блокировку потока со всеми вытекающими сложностями и случайными неожиданными проблемами с производительностью.

Другой пример, связанный с проблемой безопасности, заключается в том, что в случае с get_pep иногда возникает временная ошибка, такая как 504, что делает все последующие запросы «провальными», пока не будет выполнено вытеснение кэша (потому что код в несвязанном режиме проходит через несколько PEP), которое приведет к повторной попытке. Это как раз те ошибки, которые вызывают предупреждение о разделяемом изменяемом состоянии!

Если мы хотим кэшировать, давайте явным образом применять его в используемом коде, и не будем делать то, что навязывает глобальная реализация. К счастью, есть способ правильно использовать кэш LRU.

Сначала удалите декоратор из реализации:

def get_pep(num):
    'Retrieve text of a Python Enhancement Proposal'
    # Same code as an in official example

Затем в используемом коде создайте кэш:

def analyze_peps():
    cached_get_pep = lru_cache(maxsize=32)(get_pep)
    all_peps, pep_by_type = analyze_index(cached_get_pep(0))
    words1 = get_words_in_peps(cached_get_pep, all_peps)
    words2 = get_words_in_informational(cached_get_pep,
                                        pep_by_type["Informational"])
    do_something(words1, words2)

Обратите внимание, что в этом примере время жизни кэша относительно ясно: мы определяем его в начале функции, передаем вызываемым функциям, а затем оно выходит из области видимости и удаляется. (Запретить одной из этих функций незаметно хранить ссылку — плохая реализация, и будет видна при код-ревью.)

Это означает, что нам не нужно беспокоиться о сбоях в кэше, если функция повторяется. Если мы попытаемся повторно выполнить analysis_peps, мы знаем, что он попытается получить любые PEP, даже если это не получилось сделать раньше.

Если бы мы хотели, чтобы кэш сохранялся между вызовами функции, правильным решением было бы переместить его на один уровень выше:

def analyze_peps(cached_get_peps):
    # ...

Тогда ответственность за поддержание кэша ляжет на вызывающую сторону: еще раз, мы избегаем разделяемого изменяемого состояния, делая управление состоянием явным.

В этом примере, основываясь на официальной документации lru_cache, мы использовали сетевую функцию, чтобы показать некоторые проблемы с глобальным кэшем. Часто lru_cache используется из соображений производительности. Однако даже в этом случае легко обрести проблемы: например, одна функция, использующая неразделяемые входные данные для функций, закэшированных в LRU, может вызвать массовое высвобождение кэша с удивительным влиянием на производительность!

Реализация lru_cache великолепна, но использование ее в качестве декоратора означает создание глобального кэша со всеми вытекающими последствиями. Использовать функцию локально — хорошая применение отличной реализации.

(Спасибо Ади Ставу, Стиву Холдену и Джеймсу Абелю за отзывы на первые наброски. Все оставшиеся вопросы — моя ответственность.)


Автор — Моше Задка, перевод — Евгений Зятев.