Trilha de aprendizado · Nível 14 · Tutorial 10

Controlar tempo e memória de caches com lru_cache

Aplique cache a cálculos adequados e avalie se a reutilização compensa a memória retida, os custos de consulta e as exigências de invalidação.

  • Nível: Avançado
  • Duração: 22 min
  • 9 passos
Controlar tempo e memória de caches com lru_cache

O que você vai percorrer

  1. Selecionar cálculos adequados à memoização Reconheça quando reutilizar um resultado preserva o contrato da função e quando estado externo ou efeitos colaterais impedem essa decisão. 2 min
  2. Aplicar lru_cache a argumentos estáveis Configure uma função síncrona com capacidade limitada de cache e reconheça os requisitos dos argumentos usados como chave. 3 min
  3. Interpretar métricas e prever o descarte LRU Leia as estatísticas do cache e acompanhe como acessos repetidos alteram a ordem de recência e o descarte. 3 min
  4. Identificar a memória mantida pelo cache Observe quais referências um cache conserva e meça por que um limite de entradas não equivale a um limite fixo de bytes. 3 min
  5. Invalidar resultados quando a fonte muda Defina uma política explícita de invalidação e teste localmente como cache_clear impede a reutilização de um valor desatualizado. 2 min
  6. Proteger o contrato de resultados mutáveis Evite que um resultado mutável armazenado em cache seja alterado por um consumidor e reapareça modificado em chamadas futuras. 2 min
  7. Comparar cache vazio, aquecido e ausência de cache Meça localmente três estados de execução e use tempos, contadores e distribuição de chaves para decidir se o cache compensa. 4 min
  8. Distinguir coerência entre threads de execução única Entenda por que um cache coerente entre threads ainda pode executar o mesmo cálculo mais de uma vez durante um miss simultâneo. 2 min
  9. Concluir uma decisão de cache com evidências Integre correção, tempo, memória e invalidação para recomendar um cache limitado ou rejeitá-lo. 3 min

O que você vai aprender

  • Selecionar funções cujos resultados podem ser reutilizados com segurança.
  • Configurar um cache limitado e inspecionar acertos, falhas e ocupação.
  • Comparar cenários de cache vazio e preenchido com padrões representativos de chamadas.
  • Definir quando limpar o cache e testar riscos de resultados desatualizados ou mutáveis.

Antes de começar

  • Criar decoradores parametrizados
  • Manter igualdade e hash coerentes
  • Medir trechos de código com timeit
  • Reduzir o pico e a retenção de memória
  • Testar o comportamento de funções decoradas

Passo 1 de 9

Selecionar cálculos adequados à memoização

Reconheça quando reutilizar um resultado preserva o contrato da função e quando estado externo ou efeitos colaterais impedem essa decisão.

O que a memoização reutiliza

Resultado associado aos argumentos

Memoização guarda o resultado de uma chamada para associá-lo aos argumentos usados. Em uma chamada futura com os mesmos argumentos, o programa pode devolver o resultado já obtido em vez de executar novamente o corpo da função.

Isso só é correto quando deixar de executar o corpo não altera o comportamento que a função promete.

Consulta ou novo cálculo

A mesma entrada pode seguir por dois caminhos: calcular na primeira vez e reutilizar o resultado nas repetições.

Diagrama mostrando argumentos iguais chegando a uma função; a primeira chamada executa um cálculo e armazena o resultado, enquanto a segunda recupera o resultado armazenado sem executar o cálculo.

A reutilização é baseada nos argumentos da chamada; ela não refaz automaticamente o trabalho original.

O contrato vem antes do cache

Exemplo

Cálculo estável

def area_circulo(raio: float) -> float:
    return 3.14159 * raio ** 2

Para um mesmo raio, a função retorna o mesmo valor e não precisa produzir nenhum efeito observável a cada chamada. É uma candidata à memoização, desde que chamadas repetidas façam parte da carga real.

Exemplo

Quando não basta olhar os argumentos

cotacao_atual = {"USD": 5.10}

def converter_para_reais(valor: float, moeda: str) -> float:
    return valor * cotacao_atual[moeda]

def registrar_acesso(usuario: str) -> None:
    print(f"Acesso registrado para {usuario}")

converter_para_reais(10, "USD") também depende de cotacao_atual. Se a cotação mudar, repetir apenas os argumentos não assegura um resultado atual. Já registrar_acesso precisa imprimir em toda chamada: pular seu corpo elimina um efeito exigido pelo contrato.

Dica

Perguntas de triagem

Antes de considerar cache, pergunte: para os mesmos argumentos, o resultado continua válido? E é aceitável que o corpo deixe de rodar? Uma resposta negativa indica que você deve evitar cache ou definir, mais adiante, uma política explícita para mudanças da fonte.

Decida pelo contrato

Qual função é a melhor candidata?

Considere que os argumentos recebidos são valores estáveis. Qual função pode reutilizar com segurança um resultado para a mesma chamada?

Repetição é hipótese, não conclusão

Só há reutilização se houver repetição

Uma função adequada não necessariamente merece cache. Se quase todas as chamadas usam argumentos novos, haverá pouca oportunidade de reutilizar resultados. Além disso, consultar e manter um cache também tem custos de tempo e memória.

A decisão completa exige observar o padrão real de chamadas. Nos próximos passos, você aplicará um cache limitado e verificará seus sinais de uso.

Justifique a decisão

Escolha uma função que você conheça ou imagine. Explique por que ela seria adequada para memoização ou por que deveria ser rejeitada, citando argumentos, estado externo e efeitos colaterais quando forem relevantes.

Escreva pelo menos 80 caracteres (0/80).

Passo 2 de 9

Aplicar lru_cache a argumentos estáveis

Configure uma função síncrona com capacidade limitada de cache e reconheça os requisitos dos argumentos usados como chave.

Um cache limitado diante da função

Decore a função com uma capacidade explícita

Importe lru_cache de functools e aplique @lru_cache(maxsize=...) imediatamente acima de uma função síncrona. Use um maxsize positivo e explícito: ele define quantas entradas o cache poderá manter, não o tamanho delas em bytes.

A cada chamada, o decorador monta uma chave a partir dos argumentos posicionais e nomeados. Se encontra essa chave, devolve o resultado guardado; se não encontra, executa a função e armazena o resultado para uma chamada futura equivalente.

Fluxo de consulta e armazenamento

A chamada repetida pode evitar a execução do corpo da função.

Diagrama mostrando uma chamada com argumentos entrando em um cache limitado; uma chave encontrada retorna um resultado armazenado, e uma chave ausente segue para a função e volta ao cache com o resultado.

A chave é consultada antes de o corpo da função ser executado.

Execute um exemplo autocontido

Faça o teste no seu computador

Crie um arquivo Python, cole o código completo abaixo e execute-o. O print dentro da função torna visível quando o corpo realmente foi executado.

cache_basico.py

Exemplo completo para execução local.

python
from functools import lru_cache


@lru_cache(maxsize=4)
def area_retangulo(largura: int, altura: int) -> int:
    print(f"calculando {largura} x {altura}")
    return largura * altura


print(area_retangulo(8, 5))
print(area_retangulo(8, 5))
print(area_retangulo(largura=8, altura=5))
print(area_retangulo(3, 7))

Exemplo

O que observar

A primeira chamada com 8, 5 imprime calculando 8 x 5. A segunda chamada posicional igual devolve 40 sem imprimir novamente: ela reutiliza o resultado. A chamada com argumentos nomeados também é aceita; seus argumentos participam da chave conforme a forma da chamada.

Chaves precisam ser hasháveis

Use argumentos estáveis como chave

Todos os argumentos posicionais e nomeados usados em uma chamada devem ser hasháveis. Valores como int, str, bytes e tuplas formadas apenas por valores hasháveis são candidatos usuais. Uma list é mutável e não é hashável; por isso não pode compor a chave.

A exigência é dos argumentos, não do retorno: a função pode retornar uma lista. Ainda assim, escolha argumentos cuja igualdade e hash não mudem enquanto puderem ser usados como chave.

Uma chamada que falha

Acrescente estas linhas ao mesmo arquivo e execute novamente.

python
@lru_cache(maxsize=4)
def ordenar(valores: tuple[int, ...]) -> list[int]:
    return sorted(valores)


print(ordenar((9, 2, 5)))   # funciona: tupla de inteiros é hashável
print(ordenar([9, 2, 5]))   # TypeError: unhashable type: 'list'

Atenção

Não tente usar objeto com hash instável

Não contorne esse requisito criando hashes que mudam conforme o objeto é alterado. Se igualdade ou hash de uma chave mudar, a consulta ao cache deixa de ter um comportamento confiável. Converta dados mutáveis para uma representação estável — como uma tupla — quando isso preservar o contrato da função.

Verifique a configuração

Complete o decorador

Para configurar uma capacidade explícita de quatro entradas, escreva o argumento que falta:

@lru_cache(____)

Relate o que ocorreu

Depois de executar os exemplos, explique por que a chamada repetida de area_retangulo(8, 5) pode evitar novo cálculo e por que ordenar([9, 2, 5]) é rejeitada.

Escreva pelo menos 80 caracteres (0/80).

Passo 3 de 9

Interpretar métricas e prever o descarte LRU

Leia as estatísticas do cache e acompanhe como acessos repetidos alteram a ordem de recência e o descarte.

Ler o estado do cache

Quatro campos para observar

O método cache_info() retorna uma estrutura com quatro números importantes:

  • hits: chamadas que reutilizaram uma entrada já armazenada.
  • misses: chamadas cuja chave não estava no cache e, por isso, executaram a função antes de armazenar o resultado.
  • maxsize: capacidade máxima configurada em quantidade de entradas.
  • currsize: quantidade de entradas armazenadas agora.

Um miss não é uma exceção: é apenas uma ausência normal no cache. E maxsize limita entradas, não bytes; duas entradas podem reter quantidades de memória muito diferentes.

Hit, miss e ocupação

A sequência abaixo ilustra um cache com duas entradas.

Diagrama de um cache com capacidade duas: chamadas para 1 e 2 causam misses e preenchem os slots; nova chamada para 1 causa hit; chamada para 3 remove 2 por ser a menos recentemente usada.

Em LRU, um hit também atualiza qual entrada foi usada mais recentemente.

Consultar as métricas

Execute este exemplo localmente após as chamadas para inspecionar o estado.

python
from functools import lru_cache

@lru_cache(maxsize=2)
def dobrar(numero: int) -> int:
    return numero * 2

for numero in (1, 2, 1, 3):
    print(numero, "->", dobrar(numero))

print(dobrar.cache_info())
# CacheInfo(hits=1, misses=3, maxsize=2, currsize=2)

Seguir a recência, não só a inserção

Acesso renova a entrada

Com maxsize=2, acompanhe dobrar(1), dobrar(2), dobrar(1), dobrar(3):

  1. 1: miss; cache contém 1.
  2. 2: miss; cache contém 1, 2. A menos recente é 1.
  3. 1: hit; 1 passa a ser a mais recente. Agora a menos recente é 2.
  4. 3: miss; seria necessária uma terceira entrada. O cache descarta 2 e mantém 1, 3.

Logo, após a sequência: hits=1, misses=3, currsize=2. LRU significa menos recentemente usada, não simplesmente “a primeira que entrou”.

Dica

Capacidade ilimitada exige cautela

@lru_cache(maxsize=None) desativa o limite de quantidade de entradas. Cada nova chave pode permanecer armazenada enquanto o cache existir; se as chaves continuarem variando, a ocupação pode crescer continuamente. Isso não torna o cache mais rápido por si só.

Prever a ordem LRU

Da menos para a mais recente

Após as chamadas dobrar(1), dobrar(2) e dobrar(1), ordene as entradas do cache da menos para a mais recentemente usada.

  1. próxima chamada nova poderá descartar a entrada menos recente
  2. entrada da chave 2
  3. entrada da chave 1

Conferir contadores e descarte

Qual chave sai?

Com maxsize=2, após as chamadas dobrar(1), dobrar(2), dobrar(1), dobrar(3), a chave descartada é ___.

Passo 4 de 9

Identificar a memória mantida pelo cache

Observe quais referências um cache conserva e meça por que um limite de entradas não equivale a um limite fixo de bytes.

O que uma entrada mantém vivo

Uma entrada é mais que um contador

Enquanto uma entrada permanece no lru_cache, o cache conserva referências aos argumentos que formam sua chave e ao resultado associado. Se esses objetos alcançam outros objetos, essa retenção também pode ser indireta.

Por isso, currsize informa quantas entradas estão ocupadas, não quantos bytes elas usam. Duas entradas podem conter resultados minúsculos; outras duas podem manter estruturas muito maiores. Também existe a sobrecarga das chaves e da própria estrutura do cache.

Referências retidas por uma entrada

O cache mantém os objetos alcançáveis pelas referências presentes na chave e no resultado.

Diagrama de um cache com duas entradas. Cada entrada aponta para argumentos usados como chave e para um resultado; os resultados apontam para outros objetos, ilustrando retenção indireta.

Enquanto a entrada existir, os objetos alcançáveis por essas referências podem continuar vivos.

Atenção

Capacidade não é orçamento de memória

maxsize=100 limita o número de resultados armazenados em 100, mas não define um teto de 100 MB, 10 MB ou qualquer quantidade de bytes. Para decidir uma capacidade, considere o tamanho e a distribuição reais de argumentos e resultados.

Métodos: a instância também entra na chave

O papel de self

Quando lru_cache decora um método de instância, uma chamada como catalogo.resumo("python") usa self — isto é, a instância catalogo — como parte da chave, além dos demais argumentos.

O método decorado fica na classe e seu cache é compartilhado entre instâncias. Assim, uma entrada pode manter a instância viva por meio da referência a self. Como self participa da chave, ele precisa ser hashável. Uma classe comum costuma herdar hash por identidade; porém, uma classe que define igualdade e não define um hash compatível pode se tornar não hashável.

Cache compartilhado no método

A chave de cada chamada identifica tanto a instância quanto os outros argumentos.

Diagrama de uma classe com um único método decorado que contém um cache compartilhado. Duas instâncias chamam o método, e as entradas do cache apontam de volta para cada instância como parte das chaves.

Mesmo após o restante do programa deixar de usar uma instância, uma entrada do método pode ainda referenciá-la.

Experimento: ocupação versus memória rastreada

Meça sem misturar com tempo

Execute este script localmente. Ele cria resultados imutáveis de tamanhos controlados e não guarda os retornos em uma lista ou variável externa. A medição usa somente tracemalloc; uma comparação de tempo deve ser um experimento separado, sem essa instrumentação.

Preenchimento e descarte de entradas grandes

Salve como memoria_cache.py e execute com Python 3.

python
from functools import lru_cache
import tracemalloc


@lru_cache(maxsize=2)
def bloco(tamanho: int) -> bytes:
    # bytes é imutável; cada tamanho representa um resultado controlado.
    return bytes(tamanho)


def mostrar(etapa: str) -> None:
    atual, pico = tracemalloc.get_traced_memory()
    info = bloco.cache_info()
    print(
        f"{etapa:18} "
        f"currsize={info.currsize} "
        f"hits={info.hits} misses={info.misses} "
        f"atual={atual / 1_000_000:.2f} MB "
        f"pico={pico / 1_000_000:.2f} MB"
    )


tracemalloc.start()

bloco(300_000)
mostrar("após 300 KB")

bloco(900_000)
mostrar("após 900 KB")

# A terceira chave excede maxsize=2 e descarta a menos recentemente usada.
bloco(1_500_000)
mostrar("após 1,5 MB")

tracemalloc.stop()

Interprete a evidência

Leitura do experimento

Após executar o script, explique por que currsize pode permanecer em 2 enquanto a memória atual rastreada aumenta ou diminui. Inclua o que o valor de pico acrescenta à observação.

Escreva pelo menos 120 caracteres (0/120).

Dica

Critério para investigar

Se um cache retém objetos grandes, inspecione primeiro quais chaves e resultados sobrevivem em entradas ocupadas. Não conclua o consumo de memória a partir de currsize isoladamente.

Passo 5 de 9

Invalidar resultados quando a fonte muda

Defina uma política explícita de invalidação e teste localmente como cache_clear impede a reutilização de um valor desatualizado.

Recência não é atualização

Cache não percebe a mudança sozinho

lru_cache não tem expiração por tempo e não observa automaticamente arquivos, configurações, bancos de dados, serviços externos nem atributos alteráveis de uma instância. Um acerto apenas informa que a chave já está armazenada; ele não confirma que a fonte ainda contém o mesmo dado.

Por isso, usar muito uma entrada pode mantê-la recente no cache, mas não a torna atual. Quando uma mudança relevante ocorre na fonte, sua aplicação precisa executar uma política explícita de invalidação antes das próximas consultas que exigem dados novos.

Valor armazenado versus fonte alterada

A mesma chamada continua encontrando a entrada já armazenada, mesmo depois de a fonte externa mudar.

Diagrama mostrando uma função com cache: uma configuração externa muda de azul para laranja, mas a consulta com a mesma chave recebe o cartão azul armazenado até que o cache seja limpo.

O cache conhece argumentos e resultados armazenados; ele não detecta sozinho uma alteração fora desses argumentos.

Teste local: observar e corrigir a desatualização

Execute este script no seu computador

O dicionário representa uma fonte externa ao argumento de preco_com_desconto. A chave do cache é apenas produto; portanto, mudar a taxa não cria uma nova chave.

Preencher, mudar, limpar e consultar

python
from functools import lru_cache

configuracao = {"desconto": 0.10}

@lru_cache(maxsize=4)
def preco_com_desconto(produto: str) -> float:
    preco_base = {"curso": 100.0}[produto]
    return preco_base * (1 - configuracao["desconto"])

print(preco_com_desconto("curso"))  # 90.0: miss, resultado armazenado
print(preco_com_desconto.cache_info())

configuracao["desconto"] = 0.25
print(preco_com_desconto("curso"))  # ainda 90.0: hit com resultado antigo
print(preco_com_desconto.cache_info())

preco_com_desconto.cache_clear()
print(preco_com_desconto.cache_info())  # hits=0, misses=0, currsize=0
print(preco_com_desconto("curso"))  # 75.0: nova execução com a fonte atual
print(preco_com_desconto.cache_info())

Dica

Onde colocar a limpeza

Associe cache_clear() ao evento que realmente altera a fonte: por exemplo, após recarregar uma configuração ou concluir uma atualização de dados. Não limpe a cada consulta sem necessidade, pois isso elimina a reutilização que justificava o cache.

O que cache_clear faz

Limpeza total e contadores reiniciados

preco_com_desconto.cache_clear() remove todas as entradas daquele cache e reinicia hits e misses. Assim, a próxima chamada não encontra a chave e executa o corpo da função novamente.

A limpeza remove as referências que o cache mantinha para seus argumentos e resultados. Isso não destrói objetos que ainda tenham outras referências no programa; apenas deixa de ser o cache quem os mantém vivos.

Organize a política de invalidação

Coloque na ordem correta as ações para obter o novo preço após uma alteração da configuração.

  1. Alterar a configuração de desconto.
  2. Consultar novamente `preco_com_desconto("curso")`.
  3. Chamar `preco_com_desconto.cache_clear()`.

Defina o evento de invalidação

Explique a evidência do teste

No script, por que o valor permanece 90.0 após alterar a configuração? Explique o que muda após cache_clear() e indique qual evento do caso deve disparar essa limpeza.

Escreva pelo menos 120 caracteres (0/120).

Passo 6 de 9

Proteger o contrato de resultados mutáveis

Evite que um resultado mutável armazenado em cache seja alterado por um consumidor e reapareça modificado em chamadas futuras.

Um acerto reutiliza o próprio objeto

Cache não cria cópias

Em uma chamada com a mesma chave, lru_cache devolve o objeto que foi armazenado no primeiro cálculo. Se esse objeto for mutável, como uma lista ou dicionário, consumidores diferentes passam a compartilhar o mesmo estado.

Uma chave, um objeto compartilhado

O segundo retorno aponta para a mesma lista já guardada pelo cache.

Diagrama mostrando duas chamadas com a mesma chave chegando a uma entrada de cache que referencia uma única lista compartilhada; uma alteração feita pela primeira chamada aparece na lista vista pela segunda.

Um hit reutiliza a referência armazenada; ele não produz uma lista equivalente nova.

Atenção

Risco de contaminação

Não use diretamente lru_cache para entregar objetos mutáveis quando o contrato promete que cada consumidor pode alterá-los de modo independente. Uma alteração local pode virar estado observável por chamadas futuras com a mesma chave.

Reproduza a contaminação

Teste local com lista em cache

Execute este script. A segunda chamada é um hit para a mesma chave.

python
from functools import lru_cache

@lru_cache(maxsize=8)
def etiquetas(categoria: str) -> list[str]:
    return [categoria, "novo"]

primeiro = etiquetas("livro")
primeiro.append("promoção")

segundo = etiquetas("livro")

print(primeiro)          # ['livro', 'novo', 'promoção']
print(segundo)           # ['livro', 'novo', 'promoção']
print(primeiro is segundo)  # True

Observe o resultado

Depois de executar o script, explique por que segundo contém "promoção".

Escreva pelo menos 40 caracteres (0/40).

Escolha o isolamento compatível com o contrato

Duas estratégias seguras

Se os consumidores não precisam modificar o retorno, prefira armazenar e devolver uma estrutura efetivamente imutável, como uma tupla. Se cada consumidor precisa de uma estrutura mutável própria, mantenha o cache em uma função interna e faça a cópia em uma camada externa, depois do hit.

Cache interno, cópia para cada consumidor

Aqui o cache guarda uma tupla imutável. A função pública cria uma lista nova em cada chamada.

python
from functools import lru_cache

@lru_cache(maxsize=8)
def _etiquetas_base(categoria: str) -> tuple[str, ...]:
    return (categoria, "novo")

def etiquetas_para_edicao(categoria: str) -> list[str]:
    return list(_etiquetas_base(categoria))

primeiro = etiquetas_para_edicao("livro")
primeiro.append("promoção")

segundo = etiquetas_para_edicao("livro")

print(primeiro)          # ['livro', 'novo', 'promoção']
print(segundo)           # ['livro', 'novo']
print(primeiro is segundo)  # False

Dica

Escolha a profundidade da cópia

list(...) basta quando os elementos não exigem isolamento adicional. Para estruturas aninhadas mutáveis, faça uma cópia compatível com o contrato — por exemplo, reconstruindo as partes mutáveis necessárias ou usando uma cópia profunda quando ela for realmente exigida.

Decida pelo contrato

Contrato e estratégia

Associe cada situação à estratégia mais adequada.

Toque em um item e depois no par correspondente.

Passo 7 de 9

Comparar cache vazio, aquecido e ausência de cache

Meça localmente três estados de execução e use tempos, contadores e distribuição de chaves para decidir se o cache compensa.

O que uma comparação precisa incluir

Cache não é ganho automático

Um acerto evita o cálculo, mas uma chamada com cache ainda consulta a chave, calcula hashes, mantém a estrutura LRU e pode exigir cópias em uma camada externa quando o contrato pede objetos independentes.

Compare a mesma sequência de argumentos em três cenários: sem cache, cache vazio e cache aquecido. O ganho depende de quanto custa o cálculo evitado, da repetição das chaves e da capacidade disponível.

Três estados, a mesma carga

Diagrama comparando uma sequência de chamadas diretas, um cache inicialmente vazio que passa a reutilizar chaves e um cache previamente preenchido que devolve resultados armazenados.

A sequência de argumentos é igual; o estado inicial do cache é o que muda entre os cenários.

Dica

Não misture preparação e medição

Limpar ou aquecer o cache dentro do trecho cronometrado responde outra pergunta. Prepare o estado antes de cada amostra; cronometre apenas o lote de chamadas que representa a carga.

Script de comparação controlada

Execute no seu computador

Copie o script inteiro para um arquivo, por exemplo comparar_cache.py, e execute com python comparar_cache.py. A função decorada não é recursiva; por isso, calcular.__wrapped__ fornece uma linha de base sem cache para o mesmo corpo da função.

Medir sem cache, vazio e aquecido

python
from functools import lru_cache
from statistics import median
from timeit import Timer

AMOSTRAS = 7


@lru_cache(maxsize=16)
def calcular(n: int) -> int:
    # Trabalho determinístico propositalmente mais caro que uma soma simples.
    total = 0
    for i in range(1, 3_000):
        total = (total + n * i) % 1_000_003
    return total


sem_cache = calcular.__wrapped__


def executar(funcao, argumentos: list[int]) -> int:
    # O mesmo lote é usado nos três cenários.
    total = 0
    for n in argumentos:
        total += funcao(n)
    return total


def mostrar(nome: str, tempos: list[float], contadores: list[tuple[int, int]]) -> None:
    print(f"  {nome:13} mínimo={min(tempos):.6f}s  mediana={median(tempos):.6f}s")
    if contadores:
        print(f"  {'':13} Δhits/Δmisses por amostra: {contadores}")


def medir_sem_cache(argumentos: list[int], esperado: int) -> None:
    tempos = []
    for _ in range(AMOSTRAS):
        resultado = executar(sem_cache, argumentos)
        assert resultado == esperado
        # A linha acima confirma equivalência; a medição usa o mesmo lote.
        tempo = Timer(lambda: executar(sem_cache, argumentos)).timeit(number=1)
        tempos.append(tempo)
    mostrar("sem cache", tempos, [])


def medir_cache(argumentos: list[int], esperado: int, aquecido: bool) -> None:
    tempos = []
    contadores = []

    for _ in range(AMOSTRAS):
        calcular.cache_clear()  # Fora da cronometragem.

        if aquecido:
            # Preparação explícita. Se houver mais de 16 chaves, nem todas caberão.
            for n in argumentos:
                calcular(n)

        antes = calcular.cache_info()
        resultado = executar(calcular, argumentos)
        assert resultado == esperado

        # A amostra medida começa no mesmo estado que acabou de ser preparado.
        calcular.cache_clear()
        if aquecido:
            for n in argumentos:
                calcular(n)
        antes = calcular.cache_info()
        tempo = Timer(lambda: executar(calcular, argumentos)).timeit(number=1)
        depois = calcular.cache_info()

        tempos.append(tempo)
        contadores.append((depois.hits - antes.hits, depois.misses - antes.misses))

    nome = "cache aquecido" if aquecido else "cache vazio"
    mostrar(nome, tempos, contadores)


def comparar(nome: str, argumentos: list[int]) -> None:
    calcular.cache_clear()
    esperado = executar(sem_cache, argumentos)

    print(f"\n{nome}: {len(argumentos)} chamadas, "
          f"{len(set(argumentos))} chaves distintas, maxsize=16")
    medir_sem_cache(argumentos, esperado)
    medir_cache(argumentos, esperado, aquecido=False)
    medir_cache(argumentos, esperado, aquecido=True)


concentrado = [i % 8 for i in range(400)]
alta_cardinalidade = list(range(400))

comparar("Reutilização concentrada", concentrado)
comparar("Alta cardinalidade", alta_cardinalidade)

Como ler as evidências

Interprete tempo e contadores juntos

No padrão concentrado, há somente 8 chaves para maxsize=16. No cache vazio, as primeiras ocorrências tendem a ser misses e as demais podem ser hits; no aquecido, o lote tende a começar com hits.

Na alta cardinalidade, há 400 chaves distintas. Preparar todas não faz todas caberem: o LRU mantém no máximo 16 entradas. Ao percorrer novamente a sequência em ordem, as entradas antigas podem ser descartadas antes de serem reutilizadas. Assim, o cache pode acrescentar custo sem evitar cálculo suficiente.

Dica

Taxa de acertos do lote

Para cada amostra, use apenas os deltas impressos: Δhits / (Δhits + Δmisses). Não inclua as chamadas usadas para aquecer o cache, pois elas pertencem à preparação, não à carga medida.

Use mínimo, mediana e variação entre amostras como evidências. Não existe uma porcentagem de acertos ou um ganho de tempo universal que obrigue o uso de cache.

Atenção

“Vazio” não significa “sem acertos”

Um lote iniciado com cache vazio pode repetir uma chave e gerar acertos depois do primeiro miss. Por isso, não chame de cenário vazio uma repetição que reutiliza o cache deixado pela amostra anterior: limpe-o antes de cada amostra, fora do tempo medido.

Decida com base na sua execução

Relatório curto da comparação

Execute o script e relate: (1) os tempos de sem cache, cache vazio e cache aquecido em cada padrão; (2) os deltas de hits e misses; e (3) sua decisão sobre usar ou não o cache em cada caso. Explique a decisão pela relação entre número de chaves, repetição e maxsize=16.

Escreva pelo menos 180 caracteres (0/180).

Passo 8 de 9

Distinguir coerência entre threads de execução única

Entenda por que um cache coerente entre threads ainda pode executar o mesmo cálculo mais de uma vez durante um miss simultâneo.

O que a coerência protege

Cache compartilhado não é coordenação de trabalho

Quando várias threads usam uma função decorada com lru_cache, a estrutura interna do cache permanece coerente: acessos e atualizações não devem corromper suas entradas nem seus contadores.

Mas essa garantia não transforma um miss em trabalho exclusivo. O cache reutiliza resultados que já foram concluídos e armazenados; ele não reserva automaticamente uma chave ausente para uma única thread calcular.

Duas threads, uma chave ausente

A janela importante ocorre entre a consulta que encontra a chave ausente e o armazenamento do resultado.

Diagrama em linha do tempo: as threads A e B consultam a mesma chave ausente, ambas calculam o resultado e depois uma entrada é armazenada no cache compartilhado.

Se ambas consultarem antes de existir uma entrada, ambas podem executar o corpo da função.

A linha do tempo de um miss simultâneo

Exemplo

Mesmo argumento, dois cálculos

Considere uma chamada para buscar_preco("A1"):

  1. A thread A consulta a chave "A1": miss.
  2. Antes de A armazenar algo, a thread B consulta "A1": também miss.
  3. A e B executam o corpo de buscar_preco("A1").
  4. Cada uma termina e o cache passa a ter uma entrada utilizável para consultas futuras.

Não houve corrupção do cache. Ainda assim, o cálculo ocorreu duas vezes, pois nenhuma das threads encontrou um resultado já concluído.

Atenção

Não use cache como garantia de efeito único

Se o corpo envia uma cobrança, grava um registro, dispara uma notificação ou produz outro efeito que precisa ocorrer exatamente uma vez, lru_cache não é o mecanismo adequado. Em um miss concorrente, o corpo pode rodar mais de uma vez.

Verifique a garantia correta

Julgue a afirmação

Se lru_cache for usado por várias threads, duas chamadas simultâneas com os mesmos argumentos sempre executarão o corpo da função uma única vez.

Dois problemas diferentes

Reutilizar não é coordenar

Use lru_cache para reutilizar resultados concluídos de cálculos seguros de repetir. Trate a coordenação de trabalho em andamento como outro problema, especialmente quando duplicar a execução é caro ou altera o mundo externo.

Resumo

Síntese

  • A estrutura interna de lru_cache permanece coerente com múltiplas threads.
  • Duas threads podem observar a mesma chave ausente antes do primeiro armazenamento.
  • Nesse intervalo, o corpo da função pode executar mais de uma vez.
  • Um cache não garante execução única de efeitos colaterais.
  • Reutilizar um resultado concluído é diferente de coordenar trabalho ainda em andamento.

Passo 9 de 9

Concluir uma decisão de cache com evidências

Integre correção, tempo, memória e invalidação para recomendar um cache limitado ou rejeitá-lo.

Um caso decidido por evidências

Dossiê: catálogo por código

Considere buscar_produto(codigo), que consulta um catálogo em memória. Para a mesma versão do catálogo e o mesmo código, a função deve devolver dados equivalentes e não tem efeito colateral. A carga real concentra consultas em 24 códigos populares, mas pode receber milhares de códigos diferentes ao longo do dia.

Medições locais com a mesma carga mostram:

  • Sem cache: 84 ms por lote.
  • lru_cache(maxsize=32) aquecido: 19 ms; 91% de acertos; currsize=32.
  • lru_cache(maxsize=None) aquecido: 18 ms; 92% de acertos; currsize=18.400 após um período maior.

Cada entrada retém os argumentos e o resultado. O orçamento do processo permite um cache pequeno, mas não crescimento contínuo.

Leitura conjunta dos dados

Compare o pequeno ganho adicional sem limite com o crescimento de entradas retidas.

Diagrama comparando cache limitado a 32 entradas, com alta taxa de acertos e memória estável, a cache sem limite, com ganho de tempo marginal e muitas entradas acumuladas.

A decisão não depende só do menor tempo: a capacidade precisa caber no orçamento de memória.

Exemplo

Recomendação fundamentada

Use @lru_cache(maxsize=32). A reutilização concentrada reduz bastante o tempo do lote, enquanto 32 entradas dão um teto para a quantidade de referências mantidas. maxsize=None melhora apenas 1 ms neste cenário, mas permite que chaves raras façam a ocupação crescer sem limite de entradas.

A limpeza deve ocorrer imediatamente após a troca, recarga ou alteração confirmada do catálogo: buscar_produto.cache_clear(), antes das próximas consultas que exigem dados atuais. Testes devem confirmar: resultado igual ao cálculo sem cache; resultado novo após alterar a fonte e limpar; e ausência de contaminação se consumidores puderem alterar o retorno.

O que a configuração não promete

Quatro verificações antes de aprovar

Uma recomendação completa responde a estas perguntas:

  1. Correção: o resultado depende apenas de argumentos e de uma fonte considerada estável entre invalidações?
  2. Tempo: os tempos e a taxa de acertos foram medidos com o padrão relevante de chamadas, comparando cache vazio, aquecido e linha de base?
  3. Memória: currsize e o tamanho potencial de argumentos/resultados cabem no orçamento? Lembre-se: currsize não mede bytes.
  4. Ciclo de vida: qual evento chama cache_clear() e quem é responsável por isso?

Se o retorno for mutável e cada consumidor precisar de independência, a camada externa ao cache deve devolver uma cópia adequada — ou o contrato deve retornar uma estrutura imutável.

Atenção

Não transforme o cache em coordenador de trabalho

Mesmo com coerência interna entre threads, duas threads podem encontrar a mesma chave ausente e executar o corpo simultaneamente antes de uma delas armazenar o resultado. Portanto, não use lru_cache para garantir uma única execução de uma operação com efeito colateral.

Escolha baseada no dossiê

Qual decisão está mais bem justificada para buscar_produto?

Aplique o critério a uma decisão

Sua recomendação técnica

Com base no dossiê, escreva uma recomendação para a equipe. Inclua: configuração ou rejeição do cache, uma evidência de tempo, uma evidência de memória, o evento de limpeza, testes de atualização e mutabilidade, e o limite entre threads.

Escreva pelo menos 280 caracteres (0/280).

Síntese da decisão

Resumo

Checklist final para `lru_cache`

Use cache como uma decisão de contrato e de medição, não como um atalho automático de desempenho.

  • Reutilize apenas resultados seguros para os mesmos argumentos e para a mesma versão relevante da fonte.
  • Escolha maxsize pelo padrão de reutilização e pelo orçamento de memória; quantidade de entradas não equivale a bytes.
  • Compare a linha de base com cache vazio e aquecido usando a carga representativa; interprete tempos e contadores juntos.
  • Associe uma mudança relevante da fonte a cache_clear() antes de consultas que precisem de dados atuais.
  • Proteja consumidores contra o compartilhamento de retornos mutáveis e não atribua ao cache garantia de execução única entre threads.

Decisão de cache concluída

Parabéns! Você concluiu: Controlar tempo e memória de caches com lru_cache

Muito bem! Agora você pode justificar uma decisão de lru_cache conciliando correção, tempo, memória, invalidação e limites de concorrência.

Baixe o Aplicativo agora para ter acesso a + de 5000 cursos gratuitos, exercícios, certificado e muito conteúdo sem pagar nada!

  • Cursos online 100% gratuitos do início ao fim

    Milhares de cursos online em vídeo, ebooks e áudiobooks.

  • Mais de 60 mil exercícios gratuitos

    Para testar seus conhecimentos no decorrer dos cursos online

  • Certificado Digital gratuito válido em todo o Brasil

    Gerado diretamente na galeria de fotos do seu celular e enviado ao seu e-mail

Aplicativo Cursa na tela de ebook, na tela de curso em vídeo e na tela de exercícios do curso, mais o certificado de conclusão de curso