Segurança de thread do MemoryCache, o bloqueio é necessário?

93

Para começar, deixe-me jogar por aí que sei que o código abaixo não é seguro para threads (correção: pode ser). O que estou lutando é encontrar uma implementação que seja e que eu possa realmente falhar no teste. Estou refatorando um grande projeto WCF agora que precisa de alguns (principalmente) dados estáticos armazenados em cache e preenchidos a partir de um banco de dados SQL. Ele precisa expirar e "atualizar" pelo menos uma vez por dia, por isso estou usando o MemoryCache.

Eu sei que o código abaixo não deve ser thread-safe, mas não consigo fazê-lo falhar sob carga pesada e complicar as coisas, uma pesquisa do Google mostra implementações em ambos os sentidos (com e sem bloqueios combinados com debates se eles são ou não necessários.

Alguém com conhecimento de MemoryCache em um ambiente multiencadeado poderia me informar definitivamente se preciso ou não bloquear onde apropriado para que uma chamada para remover (que raramente será chamada, mas é um requisito) não será lançada durante a recuperação / repopulação.

public class MemoryCacheService : IMemoryCacheService
{
    private const string PunctuationMapCacheKey = "punctuationMaps";
    private static readonly ObjectCache Cache;
    private readonly IAdoNet _adoNet;

    static MemoryCacheService()
    {
        Cache = MemoryCache.Default;
    }

    public MemoryCacheService(IAdoNet adoNet)
    {
        _adoNet = adoNet;
    }

    public void ClearPunctuationMaps()
    {
        Cache.Remove(PunctuationMapCacheKey);
    }

    public IEnumerable GetPunctuationMaps()
    {
        if (Cache.Contains(PunctuationMapCacheKey))
        {
            return (IEnumerable) Cache.Get(PunctuationMapCacheKey);
        }

        var punctuationMaps = GetPunctuationMappings();

        if (punctuationMaps == null)
        {
            throw new ApplicationException("Unable to retrieve punctuation mappings from the database.");
        }

        if (punctuationMaps.Cast<IPunctuationMapDto>().Any(p => p.UntaggedValue == null || p.TaggedValue == null))
        {
            throw new ApplicationException("Null values detected in Untagged or Tagged punctuation mappings.");
        }

        // Store data in the cache
        var cacheItemPolicy = new CacheItemPolicy
        {
            AbsoluteExpiration = DateTime.Now.AddDays(1.0)
        };

        Cache.AddOrGetExisting(PunctuationMapCacheKey, punctuationMaps, cacheItemPolicy);

        return punctuationMaps;
    }

    //Go oldschool ADO.NET to break the dependency on the entity framework and need to inject the database handler to populate cache
    private IEnumerable GetPunctuationMappings()
    {
        var table = _adoNet.ExecuteSelectCommand("SELECT [id], [TaggedValue],[UntaggedValue] FROM [dbo].[PunctuationMapper]", CommandType.Text);
        if (table != null && table.Rows.Count != 0)
        {
            return AutoMapper.Mapper.DynamicMap<IDataReader, IEnumerable<PunctuationMapDto>>(table.CreateDataReader());
        }

        return null;
    }
}
James Legan
fonte
ObjectCache é thread-safe, não acho que sua classe possa falhar. msdn.microsoft.com/en-us/library/… Você pode estar acessando o banco de dados ao mesmo tempo, mas isso só usará mais cpu do que o necessário.
the_lotus
1
Embora ObjectCache seja thread-safe, as implementações dele podem não ser. Daí a questão do MemoryCache.
Haney

Respostas:

79

O padrão fornecido pelo MS MemoryCacheé totalmente seguro para threads. Qualquer implementação customizada que deriva de MemoryCachepode não ser thread-safe. Se você estiver usando o produto pronto MemoryCachepara uso , ele é seguro para threads. Navegue pelo código-fonte da minha solução de cache distribuído de código aberto para ver como eu o uso (MemCache.cs):

https://github.com/haneytron/dache/blob/master/Dache.CacheHost/Storage/MemCache.cs

Haney
fonte
2
David, Só para confirmar, na classe de exemplo muito simples que tenho acima, a chamada para .Remove () é de fato thread-safe se uma thread diferente estiver no processo de chamar Get ()? Acho que deveria apenas usar o refletor e cavar mais fundo, mas há muitas informações conflitantes por aí.
James Legan de
12
É thread-safe, mas sujeito a condições de corrida ... Se seu Get acontecer antes de sua Remove, os dados serão retornados no Get. Se a remoção acontecer primeiro, isso não acontecerá. Isso é muito parecido com leituras sujas em um banco de dados.
Haney
10
vale a pena mencionar (como comentei em outra resposta abaixo), a implementação do núcleo do dotnet NÃO é totalmente thread-safe. especificamente o GetOrCreatemétodo. há um problema no github
AmitE
O cache de memória lança uma exceção "Sem Memória" ao executar threads usando o console?
Faseeh Haris
50

Embora MemoryCache seja de fato thread-safe como outras respostas especificaram, ele tem um problema comum de multi-threading - se 2 threads tentarem Get(ou verificar Contains) o cache ao mesmo tempo, ambos perderão o cache e ambos acabarão gerando o resultado e ambos irão adicionar o resultado ao cache.

Freqüentemente, isso é indesejável - o segundo encadeamento deve aguardar a conclusão do primeiro e usar seu resultado em vez de gerar resultados duas vezes.

Esse foi um dos motivos pelos quais escrevi LazyCache - um invólucro amigável do MemoryCache que resolve esse tipo de problema. Também está disponível no Nuget .

Alastairtree
fonte
"tem um problema comum de multiencadeamento" É por isso que você deve usar métodos atômicos como AddOrGetExisting, em vez de implementar lógica personalizada Contains. O AddOrGetExistingmétodo de MemoryCache é atomic e threadsafe referenceource.microsoft.com/System.Runtime.Caching/R/…
Alex
1
Sim AddOrGetExisting é thread-safe. Mas assume que você já tem uma referência ao objeto que seria adicionado ao cache. Normalmente você não quer AddOrGetExisting, você quer "GetExistingOrGenerateThisAndCacheIt" que é o que LazyCache oferece.
Alastairtree
sim, concordou com o ponto "se você ainda não tiver o obejct"
Alex
O AddOrGetExisting não é atômico de fato. Consulte github.com/dotnet/runtime/issues/36499
Andrew
18

Como outros afirmaram, MemoryCache é realmente thread-safe. A segurança do thread dos dados armazenados nele, no entanto, depende inteiramente do seu uso.

Para citar Reed Copsey de sua postagem incrível sobre concorrência e o ConcurrentDictionary<TKey, TValue>tipo. O que é obviamente aplicável aqui.

Se dois threads chamam isso [GetOrAdd] simultaneamente, duas instâncias de TValue podem ser facilmente construídas.

Você pode imaginar que isso seria especialmente ruim se sua TValueconstrução fosse cara.

Para contornar isso, você pode alavancar Lazy<T>facilmente, o que, coincidentemente, é muito barato de construir. Fazer isso garante que, se entrarmos em uma situação multithread, estaremos apenas construindo várias instâncias Lazy<T>(o que é barato).

GetOrAdd()( GetOrCreate()no caso de MemoryCache) retornará o mesmo, singular Lazy<T>para todos os threads, as instâncias "extras" de Lazy<T>são simplesmente descartadas.

Como o Lazy<T>não faz nada até .Valueser chamado, apenas uma instância do objeto é construída.

Agora, para algum código! Abaixo está um método de extensão para o IMemoryCachequal implementa o acima. É arbitrariamente definido com SlidingExpirationbase em um int secondsparâmetro de método. Mas isso é totalmente personalizável com base nas suas necessidades.

Observe que isso é específico para aplicativos .netcore2.0

public static T GetOrAdd<T>(this IMemoryCache cache, string key, int seconds, Func<T> factory)
{
    return cache.GetOrCreate<T>(key, entry => new Lazy<T>(() =>
    {
        entry.SlidingExpiration = TimeSpan.FromSeconds(seconds);

        return factory.Invoke();
    }).Value);
}

Chamar:

IMemoryCache cache;
var result = cache.GetOrAdd("someKey", 60, () => new object());

Para realizar tudo isso de forma assíncrona, recomendo usar a excelente AsyncLazy<T>implementação de Stephen Toub encontrada em seu artigo no MSDN. Que combina o inicializador lento embutido Lazy<T>com a promessa Task<T>:

public class AsyncLazy<T> : Lazy<Task<T>>
{
    public AsyncLazy(Func<T> valueFactory) :
        base(() => Task.Factory.StartNew(valueFactory))
    { }
    public AsyncLazy(Func<Task<T>> taskFactory) :
        base(() => Task.Factory.StartNew(() => taskFactory()).Unwrap())
    { }
}   

Agora, a versão assíncrona de GetOrAdd():

public static Task<T> GetOrAddAsync<T>(this IMemoryCache cache, string key, int seconds, Func<Task<T>> taskFactory)
{
    return cache.GetOrCreateAsync<T>(key, async entry => await new AsyncLazy<T>(async () =>
    { 
        entry.SlidingExpiration = TimeSpan.FromSeconds(seconds);

        return await taskFactory.Invoke();
    }).Value);
}

E finalmente, para ligar para:

IMemoryCache cache;
var result = await cache.GetOrAddAsync("someKey", 60, async () => new object());
pim
fonte
2
Eu tentei, e não parece funcionar (dot net core 2.0). cada GetOrCreate criará uma nova instância Lazy, e o cache será atualizado com a nova Lazy e, portanto, os get's avaliados \ criados várias vezes (em um env de várias threads).
AmitE
2
a partir da aparência dele, .netcore 2.0 MemoryCache.GetOrCreatenão é thread-safe da mesma forma ConcurrentDictionaryé
Amite
Provavelmente uma pergunta boba, mas você tem certeza de que foi o valor que foi criado várias vezes e não o Lazy? Se sim, como você validou isso?
pim
3
Usei uma função de fábrica que imprime na tela quando foi usada + gerar um número aleatório, e iniciar 10 threads todas tentando GetOrCreateusar a mesma chave e aquela fábrica. o resultado, fábrica foi usado 10 vezes ao usar com cache de memória (viu as impressões) + cada vez que GetOrCreateretornou um valor diferente! Fiz o mesmo teste usando ConcurrentDicionarye vi a fábrica sendo usada apenas uma vez, e obtive o mesmo valor sempre. Encontrei um problema fechado nele no github, acabei de escrever um comentário lá que deveria ser reaberto
AmitE
Aviso: esta resposta não está funcionando: Lazy <T> não está impedindo várias execuções da função em cache. Veja a resposta @snicker. [net core 2.0]
Fernando Silva
11

Confira este link: http://msdn.microsoft.com/en-us/library/system.runtime.caching.memorycache(v=vs.110).aspx

Vá até o final da página (ou pesquise o texto "Thread Safety").

Você verá:

^ Thread Safety

Este tipo é seguro para threads.

EkoostikMartin
fonte
7
Eu parei de confiar na definição do MSDN de "Thread Safe" por conta própria há algum tempo, com base na experiência pessoal. Aqui está uma boa leitura: link
James Legan
3
Essa postagem é um pouco diferente do link que forneci acima. A distinção é muito importante porque o link que forneci não ofereceu nenhuma advertência à declaração de segurança de rosca. Eu também tenho experiência pessoal usando MemoryCache.Defaultem alto volume (milhões de acessos de cache por minuto) sem problemas de threading ainda.
EkoostikMartin
Acho que o que eles querem dizer é que as operações de leitura e gravação ocorrem atomicamente. Resumidamente, enquanto o thread A tenta ler o valor atual, ele sempre lê operações de gravação concluídas, não no meio da gravação de dados na memória por qualquer thread. Quando o thread A tenta para escrever na memória, nenhuma intervenção poderia ser feita por qualquer tópico. É o que eu entendo, mas há muitas perguntas / artigos sobre isso que não levam a uma conclusão completa como a seguinte. stackoverflow.com/questions/3137931/msdn-what-is-thread-safety
erhan355
3

Acabei de carregar a biblioteca de amostras para resolver o problema do .Net 2.0.

Dê uma olhada neste repo:

RedisLazyCache

Estou usando o cache do Redis, mas também failover ou apenas Memorycache se o Connectionstring estiver ausente.

É baseado na biblioteca LazyCache que garante a execução única de callback para escrita em um evento de multi threading tentando carregar e salvar dados, especialmente se o callback for muito caro para executar.

Francis Marasigan
fonte
Compartilhe apenas as respostas, outras informações podem ser compartilhadas como comentários
ElasticCode
1
@WaelAbbas. Eu tentei, mas parece que preciso de 50 reputações primeiro. : D. Embora não seja uma resposta direta à pergunta do OP (pode ser respondida por Sim / Não com alguma explicação do porquê), minha resposta é para uma possível solução para a resposta Não.
Francis Marasigan
2

O cache é threadsafe, mas como outros afirmaram, é possível que GetOrAdd chame a função de vários tipos se for chamada de vários tipos.

Aqui está minha solução mínima para isso

private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1);

e

await _cacheLock.WaitAsync();
var data = await _cache.GetOrCreateAsync(key, entry => ...);
_cacheLock.Release();
Anders
fonte
Eu acho uma boa solução, mas se eu tiver vários métodos alterando caches diferentes, se eu usar o bloqueio eles serão bloqueados sem necessidade! Nesse caso, devemos ter vários _cacheLocks, acho que seria melhor se o cachelock também pudesse ter uma chave!
Daniel
Existem muitas maneiras de resolver isso, uma é genérica, o semáforo será único por instância de MyCache <T>. Então você pode registrá-lo AddSingleton (typeof (IMyCache <>), typeof (MyCache <>));
Anders
Eu provavelmente não faria de todo o cache um singleton que pudesse causar problemas se você precisar invocar outros tipos de transientes. Então, talvez tenha uma loja de semáforo ICacheLock <T> que é singleton
Anders
O problema com esta versão é que, se você tiver 2 coisas diferentes para armazenar em cache ao mesmo tempo, terá que esperar que a primeira termine de gerar antes de verificar o cache da segunda. É mais eficiente para ambos poderem verificar o cache (e gerar) ao mesmo tempo se as chaves forem diferentes. LazyCache usa uma combinação de Lazy <T> e bloqueio listrado para garantir o cache de itens o mais rápido possível e gerar apenas uma vez por chave. Consulte github.com/alastairtree/lazycache
alastairtree
1

Conforme mencionado por @AmitE na resposta de @pimbrouwers, seu exemplo não está funcionando conforme demonstrado aqui:

class Program
{
    static async Task Main(string[] args)
    {
        var cache = new MemoryCache(new MemoryCacheOptions());

        var tasks = new List<Task>();
        var counter = 0;

        for (int i = 0; i < 10; i++)
        {
            var loc = i;
            tasks.Add(Task.Run(() =>
            {
                var x = GetOrAdd(cache, "test", TimeSpan.FromMinutes(1), () => Interlocked.Increment(ref counter));
                Console.WriteLine($"Interation {loc} got {x}");
            }));
        }

        await Task.WhenAll(tasks);
        Console.WriteLine("Total value creations: " + counter);
        Console.ReadKey();
    }

    public static T GetOrAdd<T>(IMemoryCache cache, string key, TimeSpan expiration, Func<T> valueFactory)
    {
        return cache.GetOrCreate(key, entry =>
        {
            entry.SetSlidingExpiration(expiration);
            return new Lazy<T>(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication);
        }).Value;
    }
}

Resultado:

Interation 6 got 8
Interation 7 got 6
Interation 2 got 3
Interation 3 got 2
Interation 4 got 10
Interation 8 got 9
Interation 5 got 4
Interation 9 got 1
Interation 1 got 5
Interation 0 got 7
Total value creations: 10

Parece que GetOrCreateretorna sempre a entrada criada. Felizmente, isso é muito fácil de corrigir:

public static T GetOrSetValueSafe<T>(IMemoryCache cache, string key, TimeSpan expiration,
    Func<T> valueFactory)
{
    if (cache.TryGetValue(key, out Lazy<T> cachedValue))
        return cachedValue.Value;

    cache.GetOrCreate(key, entry =>
    {
        entry.SetSlidingExpiration(expiration);
        return new Lazy<T>(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication);
    });

    return cache.Get<Lazy<T>>(key).Value;
}

Isso funciona conforme o esperado:

Interation 4 got 1
Interation 9 got 1
Interation 1 got 1
Interation 8 got 1
Interation 0 got 1
Interation 6 got 1
Interation 7 got 1
Interation 2 got 1
Interation 5 got 1
Interation 3 got 1
Total value creations: 1
Snicker
fonte
2
Isso também não funciona. Experimente várias vezes e você verá que às vezes o valor nem sempre é 1.
mlessard