Implémentations C # dans C # .NET

Bonjour, Habr! En prévision du début du cours "Développeur C # ASP.NET Core" , nous avons préparé une traduction de matériel intéressant sur l'implémentation du cache en C #. Bonne lecture.



La mise en cache est l'un des modèles les plus couramment utilisés dans le développement de logiciels . C'est un concept simple et en même temps très efficace. L'idée est de réutiliser les résultats des opérations effectuées. Après une opération longue, nous enregistrons le résultat dans notre conteneur de cache . La prochaine fois que nous aurons besoin de ce résultat, nous l'extraireons du conteneur de cache, au lieu d'avoir à refaire une opération laborieuse.

Par exemple, pour obtenir un avatar d'utilisateur, vous devrez peut-être le demander à la base de données. Au lieu d'exécuter la demande à chaque appel, nous enregistrerons cet avatar dans le cache, en l'extrayant de la mémoire chaque fois que vous en aurez besoin.

La mise en cache est idéale pour les données qui changent rarement. Ou, idéalement, ils ne changent jamais. Les données qui changent constamment, par exemple l'heure actuelle, ne doivent pas être mises en cache, sinon vous courez le risque d'obtenir des résultats incorrects.

Cache local, cache local persistant et cache distribué


Il existe 3 types de caches:

  • Le cache en mémoire est utilisé dans les cas où vous avez juste besoin d'implémenter le cache en un seul processus. Lorsqu'un processus meurt, le cache meurt avec lui. Si vous exécutez le même processus sur plusieurs serveurs, vous disposerez d'un cache distinct pour chaque serveur.
  • Cache persistant en cours - c'est lorsque vous sauvegardez le cache en dehors de la mémoire du processus. Il peut être localisé dans un fichier ou dans une base de données. Il est plus complexe que le cache en mémoire, mais si votre processus redémarre, le cache n'est pas vidé. Idéal pour les cas où l'obtention d'un élément mis en cache coûte cher et que votre processus a tendance à redémarrer fréquemment.
  • Le cache distribué est lorsque vous avez besoin d'un cache partagé pour plusieurs machines. Il s'agit généralement de plusieurs serveurs. Le cache distribué est stocké dans un service externe. Cela signifie que si un serveur a conservé un élément de cache, d'autres serveurs peuvent également l'utiliser. Des services comme Redis sont parfaits pour cela.

Nous ne parlerons que du cache local .

Implémentation primitive


Commençons par créer une implémentation de cache très simple en 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];
    }
}

En utilisant:

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

Ce code simple résout un problème important. Pour obtenir un avatar d'utilisateur, seule la première demande sera la demande réelle de la base de données. Les données d'avatar ( byte []) par le résultat de la demande sont stockées dans la mémoire de processus. Toutes les demandes d'avatar ultérieures le récupéreront de la mémoire, économisant du temps et des ressources.

Mais, comme la plupart des choses en programmation, les choses ne sont pas si simples. La mise en œuvre ci-dessus n'est pas une bonne solution pour un certain nombre de raisons. D'une part, cette implémentation n'est pas sécurisée pour les threads . Lorsqu'il est utilisé à partir de plusieurs threads, des exceptions peuvent se produire. De plus, les éléments mis en cache resteront en mémoire pour toujours, ce qui est en fait très mauvais.

C'est pourquoi nous devons supprimer les éléments du cache:

  1. Le cache peut commencer à occuper beaucoup de mémoire, ce qui conduit finalement à des exceptions en raison de sa pénurie et de ses plantages.
  2. Une consommation élevée de mémoire peut entraîner une pression mémoire (également connue sous le nom de pression GC ). Dans cet état, le garbage collector fonctionne beaucoup plus qu'il ne devrait, ce qui réduit les performances.
  3. Le cache peut devoir être mis à jour lorsque les données changent. Notre infrastructure de mise en cache doit prendre en charge cette fonctionnalité.

Pour résoudre ces problèmes existent dans les cadres des politiques de déplacement (également connu sous le nom de suppression de la politique - Expulsion / politiques de suppression ). Ce sont les règles pour supprimer des éléments du cache selon la logique donnée. Les politiques de suppression les plus courantes sont les suivantes:

  • Une politique d'expiration absolue qui supprime un élément du cache après un certain temps, quoi qu'il arrive.
  • Une politique d'expiration glissante qui supprime un élément du cache s'il n'a pas été consulté pendant un certain temps. Autrement dit, si je règle le délai d'expiration sur 1 minute, l'élément restera dans le cache pendant que je l'utilise toutes les 30 secondes. Si je ne l'utilise pas pendant plus d'une minute, l'élément sera supprimé.
  • Politique de limite de taille , qui limitera la taille du cache.

Maintenant que nous avons compris tout ce dont nous avons besoin, passons à de meilleures solutions.

De meilleures solutions


À ma grande déception en tant que blogueur, Microsoft a déjà créé une merveilleuse implémentation de cache. Cela m'a privé du plaisir de créer moi-même une implémentation similaire, mais au moins la rédaction de cet article l'est également moins.

Je vais vous montrer une solution Microsoft, comment l'utiliser efficacement, puis comment l'améliorer pour certains scénarios.

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


Microsoft propose 2 solutions, 2 packages de mise en cache NuGet différents. Les deux sont super. Selon les recommandations de Microsoft, il est préférable d'utiliser Microsoft.Extensions.Caching.Memory, car il s'intègre mieux avec Asp. NET Core. Il peut être facilement intégré dans le mécanisme d'injection de dépendance Asp .NET Core.

Voici un exemple simple avec 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;
    }
}

En utilisant:

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

Cela me rappelle beaucoup le mien NaiveCache, alors qu'est-ce qui a changé? Eh bien, tout d'abord, c'est une implémentation thread-safe . Vous pouvez l'appeler en toute sécurité à partir de plusieurs threads à la fois.

Deuxièmement, il MemoryCacheprend en compte toutes les politiques d'éviction dont nous avons parlé précédemment. Voici un exemple:

IMemoryCache avec des politiques de préemption:

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

Analysons les nouveaux éléments:

  1. Le MemoryCacheOptions a été ajouté SizeLimit. Cela ajoute une politique de limite de taille à notre conteneur de cache. Le cache n'a pas de mécanisme pour mesurer la taille des enregistrements. Par conséquent, nous devons définir la taille de chaque entrée de cache. Dans ce cas, chaque fois que nous définissons la taille sur 1 avec SetSize(1). Cela signifie que notre cache aura une limite de 1024 éléments.
  2. , ? .SetPriority (CacheItemPriority.High). : Low (), Normal (), High () NeverRemove ( ).
  3. SetSlidingExpiration(TimeSpan.FromSeconds(2)) 2 . , 2 , .
  4. SetAbsoluteExpiration(TimeSpan.FromSeconds(10)) 10 . , 10 , .

En plus des options de l'exemple, vous pouvez également définir un délégué RegisterPostEvictionCallbackqui sera appelé lorsque l'élément sera supprimé.

Il s'agit d'un éventail assez large de fonctions, mais, néanmoins, nous devons nous demander s'il y a autre chose à ajouter. Il y a en fait deux ou trois choses.

Problèmes et fonctionnalités manquantes


Il manque plusieurs parties importantes de cette implémentation.

  1. Bien que vous puissiez définir une limite de taille, la mise en cache ne contrôle pas réellement la pression de la mémoire. Si nous surveillions, nous pourrions resserrer la politique à haute pression et affaiblir la politique à faible.
  2. , . . , , , 10 . 2 , , ( ), .

Concernant le premier problème de pression sur gc: il est possible de contrôler la pression sur gc par plusieurs méthodes et heuristiques. Cet article ne traite pas de cela, mais vous pouvez lire mon article «Recherche, correction et prévention des fuites de mémoire dans C # .NET: 8 meilleures pratiques» pour en savoir plus sur certaines méthodes utiles.

Le deuxième problème est plus facile à résoudre . En fait, voici une implémentation MemoryCachequi le résout complètement:

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

En utilisant:

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

Dans cette implémentation, lorsque vous essayez d'obtenir un élément, si le même élément est déjà en cours de création par un autre thread, vous attendez la fin du premier thread. Ensuite, vous obtiendrez un élément déjà mis en cache créé par un autre thread.

Analyse de code


Cette implémentation bloque la création d'un élément. Le verrouillage se produit sur une clé. Par exemple, si nous attendons l'avatar d'Alexey, nous pouvons toujours obtenir les valeurs mises en cache de Zhenya ou Barbara dans un autre thread.

Le dictionnaire _locksstocke tous les verrous. Les verrous réguliers ne fonctionnent pas async/await, nous devons donc utiliser SemaphoreSlim .

Il y a 2 vérifications pour vérifier si la valeur est déjà mise en cache si (!_Cache.TryGetValue(key, out cacheEntry)). Celui dans la serrure est celui qui fournit la seule création de l'élément. Celui qui est en dehors de la serrure, pour l'optimisation.

Quand utiliser WaitToFinishMemoryCache


Cette implémentation a évidemment des frais généraux. Voyons quand c'est pertinent.

À utiliser WaitToFinishMemoryCachelorsque:

  • Lorsque l'heure de création d'un élément a une valeur et que vous souhaitez minimiser autant que possible le nombre de créations.
  • Lorsque le temps de créer un élément est très long.
  • Quand un élément doit être créé une fois pour chaque clé.

Ne pas utiliser WaitToFinishMemoryCachelorsque:

  • Il n'y a aucun danger que plusieurs threads accèdent au même élément de cache.
  • Vous n'êtes pas catégoriquement contre la création d'éléments plus d'une fois. Par exemple, si une requête supplémentaire dans la base de données n'affecte pas grand-chose.

Sommaire


La mise en cache est un modèle très puissant. Et il est également dangereux et a ses pièges. Cachez trop et vous pouvez exercer une pression sur le CPG. Cachez trop peu et vous pouvez provoquer des problèmes de performances. Il existe également une mise en cache distribuée, qui représente un tout nouveau monde à explorer. C'est du développement logiciel, il y a toujours quelque chose de nouveau qui peut être maîtrisé.

J'espère que cet article vous a plu. Si vous êtes intéressé par la gestion de la mémoire, mon prochain article se concentrera sur les dangers de la pression sur le GC et comment l'empêcher, alors inscrivez-vous . Profitez de votre codage.



En savoir plus sur le cours.



All Articles