Seguridad de subprocesos de MemoryCache, ¿es necesario el bloqueo?

91

Para empezar, déjeme decir que sé que el siguiente código no es seguro para subprocesos (corrección: podría serlo). Con lo que estoy luchando es con encontrar una implementación que sea y una que realmente pueda fallar bajo prueba. Estoy refactorizando un gran proyecto WCF en este momento que necesita algunos datos estáticos (en su mayoría) almacenados en caché y se rellenan desde una base de datos SQL. Debe caducar y "actualizarse" al menos una vez al día, por lo que estoy usando MemoryCache.

Sé que el código a continuación no debería ser seguro para subprocesos, pero no puedo hacer que falle bajo una carga pesada y para complicar las cosas, una búsqueda en Google muestra implementaciones en ambos sentidos (con y sin bloqueos combinados con debates sobre si son necesarios o no).

¿Podría alguien con conocimiento de MemoryCache en un entorno de subprocesos múltiples dejarme saber definitivamente si necesito o no bloquear donde sea apropiado para que una llamada para eliminar (que rara vez se llamará pero es un requisito) no se lanzará durante la recuperación / repoblación.

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
fuente
ObjectCache es seguro para subprocesos, no creo que su clase pueda fallar. msdn.microsoft.com/en-us/library/… Es posible que vaya a la base de datos al mismo tiempo, pero eso solo usará más CPU de la necesaria.
the_lotus
1
Si bien ObjectCache es seguro para subprocesos, es posible que sus implementaciones no lo sean. De ahí la cuestión de MemoryCache.
Haney

Respuestas:

78

El MS predeterminado proporcionado MemoryCachees completamente seguro para subprocesos. Cualquier implementación personalizada que se derive de MemoryCachepuede no ser segura para subprocesos. Si está usando simple MemoryCachefuera de la caja, es seguro para subprocesos. Examine el código fuente de mi solución de almacenamiento en caché distribuido de código abierto para ver cómo lo uso (MemCache.cs):

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

Haney
fuente
2
David, solo para confirmar, en la clase de ejemplo muy simple que tengo arriba, la llamada a .Remove () es de hecho segura para subprocesos si un subproceso diferente está en el proceso de llamar a Get ()? Supongo que debería usar el reflector y profundizar, pero hay mucha información contradictoria.
James Legan
12
Es seguro para subprocesos, pero propenso a condiciones de carrera ... Si su Get ocurre antes de su Remove, los datos se devolverán en el Get. Si la eliminación ocurre primero, no será así. Esto se parece mucho a las lecturas sucias de una base de datos.
Haney
10
Vale la pena mencionar (como comenté en otra respuesta a continuación), la implementación del núcleo de dotnet actualmente NO es completamente segura para subprocesos. específicamente el GetOrCreatemétodo. hay un problema sobre eso en github
AmitE
¿La caché de memoria genera una excepción "Sin memoria" mientras se ejecutan subprocesos usando la consola?
Faseeh Haris
49

Si bien MemoryCache es seguro para subprocesos, como han especificado otras respuestas, tiene un problema común de subprocesos múltiples: si 2 subprocesos intentan salir Get(o verificar Contains) el caché al mismo tiempo, ambos perderán el caché y ambos terminarán generando el resultado y ambos agregarán el resultado a la caché.

A menudo, esto no es deseable: el segundo hilo debe esperar a que se complete el primero y usar su resultado en lugar de generar resultados dos veces.

Esta fue una de las razones por las que escribí LazyCache , un contenedor amigable en MemoryCache que resuelve este tipo de problemas. También está disponible en Nuget .

alastairtree
fuente
"tiene un problema común de subprocesos múltiples" Es por eso que debe usar métodos atómicos como AddOrGetExisting, en lugar de implementar lógica personalizada Contains. El AddOrGetExistingmétodo de MemoryCache es referencias
Alex
1
Sí AddOrGetExisting es seguro para subprocesos. Pero asume que ya tiene una referencia al objeto que se agregaría a la caché. Normalmente no quieres AddOrGetExisting, quieres "GetExistingOrGenerateThisAndCacheIt", que es lo que te ofrece LazyCache.
alastairtree
sí, estuvo de acuerdo en el punto "si aún no tiene el obejct"
Alex
17

Como han dicho otros, MemoryCache es seguro para subprocesos. Sin embargo, la seguridad de los subprocesos de los datos almacenados en él depende completamente de su uso.

Para citar a Reed Copsey de su impresionante publicación sobre la concurrencia y el ConcurrentDictionary<TKey, TValue>tipo. Lo cual, por supuesto, es aplicable aquí.

Si dos hilos llaman a esto [GetOrAdd] simultáneamente, se pueden construir fácilmente dos instancias de TValue.

Puede imaginar que esto sería especialmente malo si TValuesu construcción es costosa.

Para solucionar esto, puede aprovechar Lazy<T>muy fácilmente, lo que casualmente es muy barato de construir. Hacer esto asegura que si nos metemos en una situación de multiproceso, solo estamos creando múltiples instancias de Lazy<T>(lo cual es barato).

GetOrAdd()( GetOrCreate()en el caso de MemoryCache) devolverá lo mismo, singular Lazy<T>a todos los subprocesos, las instancias "extra" de Lazy<T>simplemente se desechan.

Dado Lazy<T>que no hace nada hasta que .Valuese llama, solo se construye una instancia del objeto.

¡Ahora un poco de código! A continuación se muestra un método de extensión para el IMemoryCacheque implementa lo anterior. Se establece arbitrariamente en SlidingExpirationfunción de un parámetro de int secondsmétodo. Pero esto es completamente personalizable según sus necesidades.

Tenga en cuenta que esto es específico de las aplicaciones .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);
}

Llamar:

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

Para realizar todo esto de forma asincrónica, recomiendo utilizar la excelente AsyncLazy<T>implementación de Stephen Toub que se encuentra en su artículo sobre MSDN. Que combina el inicializador perezoso incorporado Lazy<T>con la promesa 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())
    { }
}   

Ahora la versión asincrónica 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);
}

Y finalmente, llamar:

IMemoryCache cache;
var result = await cache.GetOrAddAsync("someKey", 60, async () => new object());
pim
fuente
2
Lo probé y no parece funcionar (dot net core 2.0). cada GetOrCreate creará una nueva instancia de Lazy, y el caché se actualiza con el nuevo Lazy, por lo que el valor se evalúa \ crea varias veces (en un entorno de subprocesos múltiples).
AmitE
2
por lo que parece, .netcore 2.0 MemoryCache.GetOrCreateno es seguro para subprocesos de la misma manera que lo ConcurrentDictionaryes
AmitE
Probablemente sea una pregunta tonta, pero ¿está seguro de que fue el Valor el que se creó varias veces y no el Lazy? Si es así, ¿cómo validaste esto?
pim
3
Usé una función de fábrica que imprime en la pantalla cuando se usa + genera un número aleatorio y comienza 10 hilos, todos tratando de GetOrCreateusar la misma clave y esa fábrica. el resultado, la fábrica se usó 10 veces cuando se usó con memoria caché (vi las impresiones) + ¡cada vez que GetOrCreatedevolvió un valor diferente! Ejecuté la misma prueba usando ConcurrentDicionaryy vi que la fábrica se usaba solo una vez, y obtuve el mismo valor siempre. Encontré un problema cerrado en github, acabo de escribir un comentario allí de que debería ser reabierto
AmitE
Advertencia: esta respuesta no funciona: Lazy <T> no impide las ejecuciones múltiples de la función en caché. Vea la respuesta de @snicker. [net core 2.0]
Fernando Silva
11

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

Vaya al final de la página (o busque el texto "Seguridad para subprocesos").

Ya verás:

^ Seguridad del hilo

Este tipo es seguro para subprocesos.

EkoostikMartin
fuente
7
Dejé de confiar en la definición de MSDN de "Thread Safe" por sí sola hace bastante tiempo, basándome en mi experiencia personal. Aquí hay una buena lectura: enlace
James Legan
3
Esa publicación es ligeramente diferente al enlace que proporcioné anteriormente. La distinción es muy importante porque el enlace que proporcioné no ofrecía ninguna advertencia a la declaración de seguridad de subprocesos. También tengo experiencia personal usando MemoryCache.Defaultun volumen muy alto (millones de accesos de caché por minuto) sin problemas de subprocesos todavía.
EkoostikMartin
Creo que lo que quieren decir es que las operaciones de lectura y escritura se realizan de forma atómica. Brevemente, mientras que el hilo A intenta leer el valor actual, siempre lee las operaciones de escritura completadas, no en medio de la escritura de datos en la memoria por cualquier hilo. para escribir en la memoria, ningún hilo podría intervenir. Eso es lo que entiendo, pero hay muchas preguntas / artículos sobre eso que no conducen a una conclusión completa como la siguiente. stackoverflow.com/questions/3137931/msdn-what-is-thread-safety
erhan355
3

Se acaba de cargar la biblioteca de muestra para solucionar el problema de .Net 2.0.

Eche un vistazo a este repositorio:

RedisLazyCache

Estoy usando la caché de Redis, pero también la conmutación por error o solo la memoria caché si falta Connectionstring.

Se basa en la biblioteca LazyCache que garantiza la ejecución única de devolución de llamada para escritura en un evento de subprocesos múltiples que intentan cargar y guardar datos, especialmente si la devolución de llamada es muy costosa de ejecutar.

Francis Marasigan
fuente
Comparta solo la respuesta, otra información se puede compartir como comentario
ElasticCode
1
@WaelAbbas. Lo intenté, pero parece que primero necesito 50 reputaciones. :RE. Aunque no es una respuesta directa a la pregunta de OP (se puede responder con Sí / No con una explicación de por qué), mi respuesta es para una posible solución para la respuesta No.
Francis Marasigan
1

Como lo mencionó @AmitE en la respuesta de @pimbrouwers, su ejemplo no funciona como se demuestra aquí:

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;
    }
}

Salida:

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 GetOrCreatesiempre devuelve la entrada creada. Afortunadamente, eso es muy fácil de solucionar:

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;
}

Eso funciona como se esperaba:

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
Risa disimulada
fuente
2
Esto tampoco funciona.
Pruébelo
1

El caché es seguro para subprocesos, pero como otros han dicho, es posible que GetOrAdd llame a la función de varios tipos si llama de varios tipos.

Aquí está mi solución mínima en eso

private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1);

y

await _cacheLock.WaitAsync();
var data = await _cache.GetOrCreateAsync(key, entry => ...);
_cacheLock.Release();
Anders
fuente
Creo que es una buena solución, pero si tengo varios métodos que cambian diferentes cachés, si uso el bloqueo, se bloquearán sin necesidad. En este caso, deberíamos tener múltiples _cacheLocks, ¡creo que sería mejor si el cachelock también pudiera tener una clave!
Daniel
Hay muchas formas de resolverlo, una es genérica, el semáforo será único por instancia de MyCache <T>. Luego puede registrarlo AddSingleton (typeof (IMyCache <>), typeof (MyCache <>));
Anders
Probablemente no haría de todo el caché un singleton que podría causar problemas si necesita invocar otros tipos transitorios. Entonces, tal vez tenga una tienda de semáforo ICacheLock <T> que sea singleton
Anders
El problema con esta versión es que si tiene 2 cosas diferentes para almacenar en caché al mismo tiempo, entonces debe esperar a que la primera termine de generarse antes de poder verificar la caché de la segunda. Es más eficiente para ambos poder verificar el caché (y generar) al mismo tiempo si las claves son diferentes. LazyCache utiliza una combinación de Lazy <T> y bloqueo a rayas para garantizar que sus elementos se almacenen en caché lo más rápido posible y solo se generen una vez por clave. Ver github.com/alastairtree/lazycache
alastairtree