MemoryCache Thread Safety, le verrouillage est-il nécessaire?

91

Pour commencer, laissez-moi simplement dire que je sais que le code ci-dessous n'est pas thread-safe (correction: peut-être). Ce avec quoi j'ai du mal, c'est de trouver une implémentation qui soit et que je puisse réellement réussir à échouer lors des tests. Je suis en train de refactoriser un grand projet WCF qui nécessite des données (principalement) statiques mises en cache et alimentées à partir d'une base de données SQL. Il doit expirer et "se rafraîchir" au moins une fois par jour, c'est pourquoi j'utilise MemoryCache.

Je sais que le code ci-dessous ne doit pas être thread-safe, mais je ne peux pas le faire échouer sous une charge lourde et pour compliquer les choses, une recherche Google montre les implémentations dans les deux sens (avec et sans verrous combinés à des débats sur la nécessité ou non de les utiliser.

Une personne connaissant MemoryCache dans un environnement multi-thread pourrait-elle me faire savoir définitivement si je dois ou non verrouiller le cas échéant afin qu'un appel à supprimer (qui sera rarement appelé mais c'est une exigence) ne sera pas lancé lors de la récupération / repeuplement.

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
la source
ObjectCache est thread-safe, je ne pense pas que votre classe puisse échouer. msdn.microsoft.com/en-us/library/ ... Vous pouvez accéder à la base de données en même temps, mais cela n'utilisera que plus de CPU que nécessaire.
the_lotus
1
Bien qu'ObjectCache soit thread-safe, ses implémentations peuvent ne pas l'être. Ainsi la question MemoryCache.
Haney

Réponses:

78

Le MS par défaut fourni MemoryCacheest entièrement thread-safe. Toute implémentation personnalisée qui dérive de MemoryCachepeut ne pas être thread-safe. Si vous utilisez plain MemoryCacheout of the box, il est thread-safe. Parcourez le code source de ma solution de mise en cache distribuée open source pour voir comment je l'utilise (MemCache.cs):

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

Haney
la source
2
David, Juste pour confirmer, dans l'exemple de classe très simple que j'ai ci-dessus, l'appel à .Remove () est en fait thread-safe si un thread différent est en train d'appeler Get ()? Je suppose que je devrais simplement utiliser un réflecteur et creuser plus profondément, mais il y a beaucoup d'informations contradictoires.
James Legan
12
Il est thread-safe, mais sujet à des conditions de concurrence critique ... Si votre Get se produit avant votre Remove, les données seront renvoyées sur le Get. Si la suppression se produit en premier, elle ne le sera pas. C'est un peu comme des lectures sales sur une base de données.
Haney
10
il convient de mentionner (comme je l'ai commenté dans une autre réponse ci-dessous), l' implémentation de dotnet core n'est actuellement PAS entièrement thread-safe. spécifiquement la GetOrCreateméthode. il y a un problème à ce sujet sur github
AmitE
Le cache mémoire génère-t-il une exception "Mémoire insuffisante" lors de l'exécution de threads à l'aide de la console?
Faseeh Haris
49

Bien que MemoryCache soit en effet thread-safe comme d'autres réponses l'ont spécifié, il a un problème de multi-threads commun - si 2 threads essaient Getde (ou vérifient Contains) le cache en même temps, alors les deux manqueront le cache et les deux finiront par générer le résultat et les deux ajouteront alors le résultat au cache.

Cela n'est souvent pas souhaitable - le deuxième thread doit attendre que le premier se termine et utiliser son résultat plutôt que de générer des résultats deux fois.

C'est l'une des raisons pour lesquelles j'ai écrit LazyCache - un wrapper convivial sur MemoryCache qui résout ce genre de problèmes. Il est également disponible sur Nuget .

alastairtree
la source
"il a un problème de multi-threading commun" C'est pourquoi vous devriez utiliser des méthodes atomiques comme AddOrGetExisting, au lieu d'implémenter une logique personnalisée autour Contains. La AddOrGetExistingméthode de MemoryCache est atomique et threadsafe referencesource.microsoft.com/System.Runtime.Caching/R/...
Alex
1
Oui AddOrGetExisting est thread-safe. Mais cela suppose que vous avez déjà une référence à l'objet qui serait ajouté au cache. Normalement, vous ne voulez pas AddOrGetExisting, vous voulez "GetExistingOrGenerateThisAndCacheIt" qui est ce que LazyCache vous donne.
alastairtree le
oui, d'accord sur le point "si vous n'avez pas déjà l'objet"
Alex
17

Comme d'autres l'ont indiqué, MemoryCache est en effet thread-safe. Cependant, la sécurité des threads des données qui y sont stockées dépend entièrement de votre utilisation.

Pour citer Reed Copsey de son impressionnante poste au sujet de concomitance et le ConcurrentDictionary<TKey, TValue>genre. Ce qui est bien sûr applicable ici.

Si deux threads appellent cela [GetOrAdd] simultanément, deux instances de TValue peuvent facilement être construites.

Vous pouvez imaginer que ce serait particulièrement mauvais si la TValueconstruction est coûteuse.

Pour contourner ce Lazy<T>problème , vous pouvez tirer parti très facilement, ce qui, par coïncidence, est très bon marché à construire. Cela garantit que si nous nous trouvons dans une situation multithread, nous ne construisons que plusieurs instances de Lazy<T>(ce qui est bon marché).

GetOrAdd()( GetOrCreate()dans le cas de MemoryCache) retournera le même, singulier Lazy<T>à tous les threads, les instances "supplémentaires" de Lazy<T>sont simplement jetées.

Puisque le Lazy<T>ne fait rien tant qu'il .Valuen'est pas appelé, une seule instance de l'objet est construite.

Maintenant pour un peu de code! Vous trouverez ci-dessous une méthode d'extension pour IMemoryCachelaquelle implémente ce qui précède. Il est arbitrairement défini SlidingExpirationsur la base d'un paramètre de int secondsméthode. Mais cela est entièrement personnalisable en fonction de vos besoins.

Notez que ceci est spécifique aux applications .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);
}

Appeler:

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

Pour effectuer tout cela de manière asynchrone, je recommande d'utiliser l' excellente AsyncLazy<T>implémentation de Stephen Toub trouvée dans son article sur MSDN. Qui combine l'initialiseur paresseux intégré Lazy<T>avec la promesse 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())
    { }
}   

Maintenant, la version asynchrone 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);
}

Et enfin, pour appeler:

IMemoryCache cache;
var result = await cache.GetOrAddAsync("someKey", 60, async () => new object());
pim
la source
2
Je l'ai essayé, et cela ne semble pas fonctionner (dot net core 2.0). chaque GetOrCreate créera une nouvelle instance Lazy, et le cache est mis à jour avec le nouveau Lazy, et ainsi, la valeur est évaluée \ créée plusieurs fois (dans un env à plusieurs threads).
AmitE
2
des regards de celui - ci, .netcore 2.0 MemoryCache.GetOrCreateest pas sûre de la même manière ConcurrentDictionaryest
amité
Probablement une question idiote, mais êtes-vous certain que c'est la valeur qui a été créée plusieurs fois et pas la Lazy? Si oui, comment avez-vous validé cela?
pim
3
J'ai utilisé une fonction d'usine qui s'imprime à l'écran lorsqu'elle a été utilisée + génère un nombre aléatoire et démarre 10 threads en essayant tous d' GetOrCreateutiliser la même clé et cette usine. le résultat, l'usine a été utilisée 10 fois lors de l'utilisation avec le cache mémoire (vu les impressions) + à chaque fois que le GetOrCreateretourne une valeur différente! J'ai exécuté le même test en utilisant ConcurrentDicionaryet j'ai vu l'usine être utilisée une seule fois, et j'ai toujours la même valeur. J'ai trouvé un problème fermé dessus dans github, je viens d'écrire un commentaire qu'il devrait être rouvert
AmitE
Attention: cette réponse ne fonctionne pas: Lazy <T> n'empêche pas les exécutions multiples de la fonction mise en cache. Voir la réponse @snicker. [net core 2.0]
Fernando Silva
11

Consultez ce lien: http://msdn.microsoft.com/en-us/library/system.runtime.caching.memorycache(v=vs.110).aspx

Allez tout en bas de la page (ou recherchez le texte «Thread Safety»).

Tu verras:

^ Sécurité du fil

Ce type est thread-safe.

EkoostikMartin
la source
7
J'ai arrêté de faire confiance à la définition de MSDN de "Thread Safe" seule il y a un certain temps, en fonction de mon expérience personnelle. Voici une bonne lecture: lien
James Legan
3
Ce message est légèrement différent du lien que j'ai fourni ci-dessus. La distinction est très importante en ce que le lien que j'ai fourni n'offrait aucune mise en garde à la déclaration de sécurité des threads. J'ai également une expérience personnelle d'utilisation MemoryCache.Defaultà très haut volume (des millions de hits de cache par minute) sans aucun problème de threading pour le moment.
EkoostikMartin
Je pense que ce qu'ils signifient, c'est que les opérations de lecture et d'écriture ont lieu de manière atomique. En bref, alors que le thread A essaie de lire la valeur actuelle, il lit toujours les opérations d'écriture terminées, pas au milieu de l'écriture de données en mémoire par n'importe quel thread. pour écrire en mémoire, aucune intervention ne peut être faite par aucun fil. C'est ce que je comprends, mais il y a beaucoup de questions / articles à ce sujet qui ne mènent pas à une conclusion complète comme suit. stackoverflow.com/questions/3137931/msdn-what-is-thread-safety
erhan355
3

Je viens de télécharger un exemple de bibliothèque pour résoudre le problème de .Net 2.0.

Jetez un œil sur ce repo:

RedisLazyCache

J'utilise le cache Redis mais il bascule également ou simplement Memorycache si Connectionstring est manquant.

Il est basé sur la bibliothèque LazyCache qui garantit une exécution unique du rappel pour l'écriture en cas de multi-threading essayant de charger et d'enregistrer des données, spécialement si le rappel est très coûteux à exécuter.

Francis Marasigan
la source
Veuillez partager la réponse uniquement, d'autres informations peuvent être partagées en tant que commentaire
ElasticCode
1
@WaelAbbas. J'ai essayé mais il semble que j'ai besoin de 50 réputations d'abord. :RÉ. Bien que ce ne soit pas une réponse directe à la question du PO (peut être répondu par Oui / Non avec quelques explications pourquoi), ma réponse est pour une solution possible pour la réponse Non.
Francis Marasigan
1

Comme mentionné par @AmitE à la réponse de @pimbrouwers, son exemple ne fonctionne pas comme démontré ici:

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

Production:

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

Il semble que GetOrCreaterenvoie toujours l'entrée créée. Heureusement, c'est très facile à réparer:

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

Cela fonctionne comme prévu:

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
la source
2
Cela ne fonctionne pas non plus. Essayez-le plusieurs fois et vous verrez que parfois la valeur n'est pas toujours de 1.
mlessard
1

Le cache est threadsafe, mais comme d'autres l'ont indiqué, il est possible que GetOrAdd appelle la fonction plusieurs types si l'appel à partir de plusieurs types.

Voici ma solution minimale à ce sujet

private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1);

et

await _cacheLock.WaitAsync();
var data = await _cache.GetOrCreateAsync(key, entry => ...);
_cacheLock.Release();
Anders
la source
Je pense que c'est une bonne solution, mais si j'ai plusieurs méthodes pour changer différents caches, si j'utilise le verrou, ils seront verrouillés sans besoin! Dans ce cas, nous devrions avoir plusieurs _cacheLocks, je pense que ce serait mieux si le cachelock pouvait aussi avoir une clé!
Daniel
Il existe de nombreuses façons de le résoudre, l'une étant générique, le sémaphore sera unique par instance de MyCache <T>. Ensuite, vous pouvez l'enregistrer AddSingleton (typeof (IMyCache <>), typeof (MyCache <>));
Anders le
Je ne ferais probablement pas de tout le cache un singleton qui pourrait entraîner des problèmes si vous devez invoquer d'autres types transitoires. Alors peut-être avoir un magasin de sémaphore ICacheLock <T> qui est singleton
Anders
Le problème avec cette version est que si vous avez 2 éléments différents à mettre en cache en même temps, vous devez attendre que le premier ait fini de générer avant de pouvoir vérifier le cache pour le second. Il est plus efficace pour les deux de pouvoir vérifier le cache (et générer) en même temps si les clés sont différentes. LazyCache utilise une combinaison de Lazy <T> et de verrouillage par bandes pour garantir la mise en cache de vos éléments le plus rapidement possible et ne générer qu'une seule fois par clé. Voir github.com/alastairtree/lazycache
alastairtree