Implementações de C # em C # .NET

Olá Habr! Antecipando o início do curso "C # ASP.NET Core Developer" , preparamos uma tradução de material interessante sobre a implementação do cache em C #. Gostar de ler.



Um dos padrões mais usados ​​no desenvolvimento de software é o cache . É um conceito simples e, ao mesmo tempo, muito eficaz. A idéia é reutilizar os resultados das operações realizadas. Após uma operação demorada, salvamos o resultado em nosso contêiner de cache . Na próxima vez que precisarmos desse resultado, extraímo-lo do contêiner de cache, em vez de precisar executar uma operação trabalhosa novamente.

Por exemplo, para obter um avatar de usuário, pode ser necessário solicitá-lo no banco de dados. Em vez de executar a solicitação em cada chamada, armazenaremos esse avatar no cache, extraindo-o da memória toda vez que você precisar.

O armazenamento em cache é excelente para dados que são alterados com pouca frequência. Ou, idealmente, eles nunca mudam. Os dados que estão constantemente mudando, por exemplo, o horário atual, não devem ser armazenados em cache, caso contrário, você corre o risco de obter resultados incorretos.

Cache local, cache local persistente e cache distribuído


Existem 3 tipos de caches:

  • O cache na memória é usado para casos em que você só precisa implementar o cache em um processo. Quando um processo morre, o cache morre com ele. Se você estiver executando o mesmo processo em vários servidores, terá um cache separado para cada servidor.
  • Cache persistente no processo - é quando você faz o backup do cache fora da memória do processo. Pode ser localizado em um arquivo ou em um banco de dados. É mais complexo que o cache da memória, mas se o processo reiniciar, o cache não será liberado. Mais adequado para casos em que a obtenção de um item em cache é dispendiosa e seu processo tende a reiniciar com frequência.
  • Cache distribuído é quando você precisa de um cache compartilhado para várias máquinas. Geralmente estes são vários servidores. O cache distribuído é armazenado em um serviço externo. Isso significa que, se um servidor reteve um elemento de cache, outros servidores também podem usá-lo. Serviços como Redis são ótimos para isso.

Falaremos apenas sobre o cache local .

Implementação primitiva


Vamos começar criando uma implementação de cache muito simples em C #:

public class NaiveCache<TItem>
{
    Dictionary<object, TItem> _cache = new Dictionary<object, TItem>();
 
    public TItem GetOrCreate(object key, Func<TItem> createItem)
    {
        if (!_cache.ContainsKey(key))
        {
            _cache[key] = createItem();
        }
        return _cache[key];
    }
}

Usando:

var _avatarCache = new NaiveCache<byte[]>();
// ...
var myAvatar = _avatarCache.GetOrCreate(userId, () => _database.GetAvatar(userId));

Este código simples resolve um problema importante. Para obter um avatar de usuário, apenas a primeira solicitação será a solicitação real do banco de dados. Os dados do avatar ( byte []) pelo resultado da solicitação são armazenados na memória do processo. Todas as solicitações subsequentes de avatar o recuperam da memória, economizando tempo e recursos.

Mas, como a maioria das coisas na programação, as coisas não são tão simples. A implementação acima não é uma boa solução por vários motivos. Por um lado, essa implementação não é segura para threads . Quando usado em vários threads, podem ocorrer exceções. Além disso, os itens em cache permanecerão na memória para sempre, o que é realmente muito ruim.

É por isso que devemos remover itens do cache:

  1. Um cache pode começar a ocupar muita memória, o que acaba gerando exceções devido à sua falta e falhas.
  2. Alto consumo de memória pode levar à pressão da memória (também conhecida como pressão do GC ). Nesse estado, o coletor de lixo trabalha muito mais do que deveria, o que reduz o desempenho.
  3. O cache pode precisar ser atualizado quando os dados forem alterados. Nossa infraestrutura de armazenamento em cache deve suportar esse recurso.

Para resolver esses problemas, existem nas estruturas das políticas de deslocamento (também conhecidas como remoção de política - políticas de remoção / remoção ). Estas são as regras para remover itens do cache de acordo com a lógica especificada. Entre as políticas de remoção comuns estão as seguintes:

  • Uma política de Expiração absoluta que remove um item do cache após um período fixo de tempo, não importa o quê.
  • Uma política de Expiração de Deslizamento que remove um item do cache se ele não for acessado por um determinado período de tempo. Ou seja, se eu definir o tempo de expiração para 1 minuto, o item permanecerá no cache enquanto eu o uso a cada 30 segundos. Se eu não usá-lo por mais de um minuto, o item será excluído.
  • Política de limite de tamanho , que limitará o tamanho do cache.

Agora que descobrimos tudo o que precisamos, vamos para melhores soluções.

Melhores Soluções


Para minha grande decepção como blogueiro, a Microsoft já criou uma maravilhosa implementação de cache. Isso me privou do prazer de criar uma implementação semelhante, mas pelo menos a redação deste artigo também é menor.

Mostrarei uma solução da Microsoft, como usá-la efetivamente e, em seguida, como aprimorá-la em alguns cenários.

System.Runtime.Caching / MemoryCache vs Microsoft.Extensions.Caching.Memory


A Microsoft possui 2 soluções, 2 pacotes de armazenamento em cache NuGet diferentes. Ambos são ótimos. De acordo com as recomendações da Microsoft, é preferível usar Microsoft.Extensions.Caching.Memory, porque se integra melhor ao Asp. NET Core. Ele pode ser facilmente integrado ao mecanismo de injeção de dependência do Asp .NET Core.

Aqui está um exemplo simples com Microsoft.Extensions.Caching.Memory:

public class SimpleMemoryCache<TItem>
{
    private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
 
    public TItem GetOrCreate(object key, Func<TItem> createItem)
    {
        TItem cacheEntry;
        if (!_cache.TryGetValue(key, out cacheEntry)) //    .
        {
            //    ,   .
            cacheEntry = createItem();
            
            //    . 
            _cache.Set(key, cacheEntry);
        }
        return cacheEntry;
    }
}

Usando:

var _avatarCache = new SimpleMemoryCache<byte[]>();
// ...
var myAvatar = _avatarCache.GetOrCreate(userId, () => _database.GetAvatar(userId));

É uma reminiscência minha NaiveCache, então o que mudou? Bem, primeiro, é uma implementação segura para threads . Você pode chamá-lo com segurança de vários threads ao mesmo tempo.

Em segundo lugar, MemoryCacheleva em consideração todas as políticas de exclusão que discutimos anteriormente. Aqui está um exemplo:

IMemoryCache com políticas de preempção:

public class MemoryCacheWithPolicy<TItem>
{
    private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions()
    {
        SizeLimit = 1024
    });
 
    public TItem GetOrCreate(object key, Func<TItem> createItem)
    {
        TItem cacheEntry;
        if (!_cache.TryGetValue(key, out cacheEntry))//    .
        {
            //    ,   . 
            cacheEntry = createItem();
 
            var cacheEntryOptions = new MemoryCacheEntryOptions()
         	.SetSize(1)// 
         	//        (  )
                .SetPriority(CacheItemPriority.High)
                //       ,    .
                 .SetSlidingExpiration(TimeSpan.FromSeconds(2))
                //       ,    .
                .SetAbsoluteExpiration(TimeSpan.FromSeconds(10));
 
            //    .
            _cache.Set(key, cacheEntry, cacheEntryOptions);
        }
        return cacheEntry;
    }
}

Vamos analisar os novos elementos:

  1. O MemoryCacheOptions foi adicionado SizeLimit. Isso adiciona uma política de limite de tamanho ao nosso contêiner de cache. O cache não possui um mecanismo para medir o tamanho dos registros. Portanto, precisamos definir o tamanho de cada entrada de cache. Nesse caso, sempre que definirmos o tamanho como 1 com SetSize(1). Isso significa que nosso cache terá um limite de 1024 elementos.
  2. , ? .SetPriority (CacheItemPriority.High). : Low (), Normal (), High () NeverRemove ( ).
  3. SetSlidingExpiration(TimeSpan.FromSeconds(2)) 2 . , 2 , .
  4. SetAbsoluteExpiration(TimeSpan.FromSeconds(10)) 10 . , 10 , .

Além das opções no exemplo, você também pode definir um delegado RegisterPostEvictionCallbackque será chamado quando o item for excluído.

Essa é uma gama bastante ampla de funções, mas, no entanto, precisamos pensar se há mais alguma coisa a acrescentar. Na verdade, existem algumas coisas.

Problemas e recursos ausentes


Há várias partes importantes ausentes nessa implementação.

  1. Embora você possa definir um limite de tamanho, o armazenamento em cache não controla a pressão da memória. Se monitorássemos, poderíamos restringir a política com alta pressão e enfraquecer a política com baixa.
  2. , . . , , , 10 . 2 , , ( ), .

Em relação ao primeiro problema de pressão no gc: é possível controlar a pressão no gc por vários métodos e heurísticas. Esta postagem não é sobre isso, mas você pode ler o meu artigo “Localizando, corrigindo e impedindo vazamentos de memória no C # .NET: 8 melhores práticas” para aprender sobre alguns métodos úteis.

O segundo problema é mais fácil de resolver . Na verdade, aqui está uma implementação MemoryCacheque a resolve completamente:

public class WaitToFinishMemoryCache<TItem>
{
    private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
    private ConcurrentDictionary<object, SemaphoreSlim> _locks = new ConcurrentDictionary<object, SemaphoreSlim>();
 
    public async Task<TItem> GetOrCreate(object key, Func<Task<TItem>> createItem)
    {
        TItem cacheEntry;
 
        if (!_cache.TryGetValue(key, out cacheEntry))//    .
        {
            SemaphoreSlim mylock = _locks.GetOrAdd(key, k => new SemaphoreSlim(1, 1));
 
            await mylock.WaitAsync();
            try
            {
                if (!_cache.TryGetValue(key, out cacheEntry))
                {
                    //    ,   .
                    cacheEntry = await createItem();
                    _cache.Set(key, cacheEntry);
                }
            }
            finally
            {
                mylock.Release();
            }
        }
        return cacheEntry;
    }
}

Usando:

var _avatarCache = new WaitToFinishMemoryCache<byte[]>();
// ...
var myAvatar =
await _avatarCache.GetOrCreate(userId, async () => await _database.GetAvatar(userId));

Nesta implementação, ao tentar obter um elemento, se o mesmo elemento já estiver em processo de criação por outro encadeamento, você aguardará até a conclusão do primeiro encadeamento. Em seguida, você receberá um item em cache criado por outro thread.

Análise de código


Esta implementação bloqueia a criação de um elemento. O bloqueio ocorre em uma chave. Por exemplo, se estamos esperando o avatar de Alexey, ainda podemos obter os valores em cache de Zhenya ou Barbara em outro segmento.

O dicionário _locksarmazena todos os bloqueios. Os bloqueios regulares não funcionam async/await, portanto, precisamos usar o SemaphoreSlim .

Existem 2 verificações para verificar se o valor já está armazenado em cache se (!_Cache.TryGetValue(key, out cacheEntry)). O da fechadura é aquele que fornece a única criação do elemento. Um que está fora do bloqueio, para otimização.

Quando usar WaitToFinishMemoryCache


Esta implementação obviamente tem alguma sobrecarga. Vamos ver quando é relevante.

Use WaitToFinishMemoryCachequando:

  • Quando o tempo de criação de um item tiver algum valor e você desejar minimizar o número de criações o máximo possível.
  • Quando o tempo para criar um item é muito longo.
  • Quando um item deve ser criado uma vez para cada chave.

Não use WaitToFinishMemoryCachequando:

  • Não há perigo de que vários threads obtenham acesso ao mesmo elemento de cache.
  • Você não é categoricamente contra a criação de elementos mais de uma vez. Por exemplo, se uma consulta adicional ao banco de dados não afetar significativamente nada.

Sumário


O armazenamento em cache é um padrão muito poderoso. E também é perigoso e tem suas armadilhas. Faça cache demais e você poderá causar pressão no GC. Cache muito pouco e você pode causar problemas de desempenho. Há também cache distribuído, que representa um mundo totalmente novo para explorar. Isso é desenvolvimento de software, sempre há algo novo que pode ser dominado.

Espero que tenha gostado deste artigo. Se você estiver interessado em gerenciamento de memória, meu próximo artigo abordará os perigos da pressão no GC e como evitá-lo, então inscreva-se . Aproveite a sua codificação.



Saiba mais sobre o curso.



All Articles