web-dev-qa-db-fra.com

Comment gérer les opérations de construction coûteuses à l'aide de MemoryCache?

Sur un projet ASP.NET MVC, nous avons plusieurs instances de données qui nécessitent une bonne quantité de ressources et de temps pour être construites. Nous voulons les mettre en cache.

MemoryCache fournit un certain niveau de sécurité des threads mais pas assez pour éviter d'exécuter plusieurs instances de code de construction en parallèle. Voici un exemple:

var data = cache["key"];
if(data == null)
{
  data = buildDataUsingGoodAmountOfResources();
  cache["key"] = data;
}

Comme vous pouvez le voir sur un site Web très fréquenté, des centaines de threads peuvent entrer simultanément dans l'instruction if jusqu'à ce que les données soient générées et rendre l'opération de construction encore plus lente, consommant inutilement les ressources du serveur.

Il existe une implémentation atomique AddOrGetExisting dans MemoryCache, mais elle nécessite de manière incorrecte "valeur à définir" au lieu de "code pour récupérer la valeur à définir", ce qui, à mon avis, rend la méthode donnée presque complètement inutile.

Nous avons utilisé notre propre échafaudage ad hoc autour de MemoryCache pour bien faire les choses, mais cela nécessite des locks explicites. Il est difficile d'utiliser des objets de verrouillage par entrée et nous nous éloignons généralement en partageant des objets de verrouillage, ce qui est loin d'être idéal. Cela m'a fait penser que les raisons d'éviter une telle convention pouvaient être intentionnelles.

J'ai donc deux questions:

  • Est-il préférable de ne pas lock construire du code? (Cela aurait pu être plus réactif pour un, je me demande)

  • Quelle est la bonne façon d'obtenir un verrouillage par entrée pour MemoryCache pour un tel verrou? La forte envie d'utiliser la chaîne key comme objet de verrouillage est supprimée au ".NET verrouillage 101".

58
Sedat Kapanoglu

Nous avons résolu ce problème en combinant Lazy<T> avec AddOrGetExisting pour éviter complètement d'avoir besoin d'un objet verrou. Voici un exemple de code (qui utilise une expiration infinie):

public T GetFromCache<T>(string key, Func<T> valueFactory) 
{
    var newValue = new Lazy<T>(valueFactory);
    // the line belows returns existing item or adds the new value if it doesn't exist
    var value = (Lazy<T>)cache.AddOrGetExisting(key, newValue, MemoryCache.InfiniteExpiration);
    return (value ?? newValue).Value; // Lazy<T> handles the locking itself
}

Ce n'est pas complet. Il existe des pièges comme la "mise en cache d'exception", vous devez donc décider de ce que vous voulez faire au cas où votre valeurFactory lèverait une exception. Cependant, l'un des avantages est la possibilité de mettre également en cache des valeurs nulles.

69
Sedat Kapanoglu

Pour l'exigence d'ajout conditionnel, j'utilise toujours ConcurrentDictionary , qui a une méthode surchargée GetOrAdd qui accepte un délégué à tirer si l'objet doit être construit.

ConcurrentDictionary<string, object> _cache = new
  ConcurrenctDictionary<string, object>();

public void GetOrAdd(string key)
{
  return _cache.GetOrAdd(key, (k) => {
    //here 'k' is actually the same as 'key'
    return buildDataUsingGoodAmountOfResources();
  });
}

En réalité, j'utilise presque toujours des dictionnaires simultanés static. J'avais l'habitude d'avoir des dictionnaires "normaux" protégés par une instance ReaderWriterLockSlim, mais dès que je suis passé à .Net 4 (il n'est disponible qu'à partir de là), j'ai commencé à convertir tous ceux que j'ai rencontrés.

Les performances de ConcurrentDictionary sont pour le moins admirables :)

Mise à jour Implémentation naïve avec sémantique d'expiration basée uniquement sur l'âge. Vous devez également vous assurer que les éléments individuels ne sont créés qu'une seule fois - conformément à la suggestion de @ usr. Mettre à jour à nouveau - comme l'a suggéré @usr - en utilisant simplement un Lazy<T> serait beaucoup plus simple - vous pouvez simplement transférer le délégué de création à cela lorsque vous l'ajoutez au dictionnaire simultané. J'aurais changé le code, car en fait mon dictionnaire de serrures n'aurait pas fonctionné de toute façon. Mais j'aurais vraiment dû y penser moi-même (après minuit ici au Royaume-Uni et je suis battu. Toute sympathie? Non bien sûr que non. Étant développeur, j'ai assez de caféine dans mes veines pour réveiller le mort).

Je recommande cependant d'implémenter l'interface IRegisteredObject avec cela, puis de l'enregistrer avec HostingEnvironment.RegisterObject méthode - cela fournirait un moyen plus propre d'arrêter le thread d'interrogation lorsque le pool d'applications s'arrête/recycle.

public class ConcurrentCache : IDisposable
{
  private readonly ConcurrentDictionary<string, Tuple<DateTime?, Lazy<object>>> _cache = 
    new ConcurrentDictionary<string, Tuple<DateTime?, Lazy<object>>>();

  private readonly Thread ExpireThread = new Thread(ExpireMonitor);

  public ConcurrentCache(){
    ExpireThread.Start();
  }

  public void Dispose()
  {
    //yeah, nasty, but this is a 'naive' implementation :)
    ExpireThread.Abort();
  }

  public void ExpireMonitor()
  {
    while(true)
    {
      Thread.Sleep(1000);
      DateTime expireTime = DateTime.Now;
      var toExpire = _cache.Where(kvp => kvp.First != null &&
        kvp.Item1.Value < expireTime).Select(kvp => kvp.Key).ToArray();
      Tuple<string, Lazy<object>> removed;
      object removedLock;
      foreach(var key in toExpire)
      {
        _cache.TryRemove(key, out removed);
      }
    }
  }

  public object CacheOrAdd(string key, Func<string, object> factory, 
    TimeSpan? expiry)
  {
    return _cache.GetOrAdd(key, (k) => { 
      //get or create a new object instance to use 
      //as the lock for the user code
        //here 'k' is actually the same as 'key' 
        return Tuple.Create(
          expiry.HasValue ? DateTime.Now + expiry.Value : (DateTime?)null,
          new Lazy<object>(() => factory(k)));
    }).Item2.Value; 
  }
}
11
Andras Zoltan

Voici un design qui suit ce que vous semblez avoir à l'esprit. Le premier verrou ne se produit que pendant une courte période. Le dernier appel à data.Value se verrouille également (en dessous), mais les clients ne bloqueront que si deux d'entre eux demandent le même élément en même temps.

public DataType GetData()
{      
  lock(_privateLockingField)
  {
    Lazy<DataType> data = cache["key"] as Lazy<DataType>;
    if(data == null)
    {
      data = new Lazy<DataType>(() => buildDataUsingGoodAmountOfResources();
      cache["key"] = data;
    }
  }

  return data.Value;
}
1
Neil

La solution de Sedat de combiner Lazy avec AddOrGetExisting est inspirante. Je dois souligner que cette solution a un problème de performances, ce qui semble très important pour une solution de mise en cache.

Si vous regardez le code de AddOrGetExisting (), vous constaterez que AddOrGetExisting () n'est pas une méthode sans verrouillage. Comparé à la méthode Get () sans verrouillage, il gaspille celui de l'avantage de MemoryCache.

Je voudrais recommander de suivre la solution, en utilisant d'abord Get () puis en utilisant AddOrGetExisting () pour éviter de créer plusieurs fois un objet.

public T GetFromCache<T>(string key, Func<T> valueFactory) 
{
    T value = (T)cache.Get(key);
    if (value != null)
    {
        return value;
    }

    var newValue = new Lazy<T>(valueFactory);
    // the line belows returns existing item or adds the new value if it doesn't exist
    var oldValue = (Lazy<T>)cache.AddOrGetExisting(key, newValue, MemoryCache.InfiniteExpiration);
    return (oldValue ?? newValue).Value; // Lazy<T> handles the locking itself
}
1
Albert Ma

En prenant la première réponse en C # 7, voici mon implémentation qui permet le stockage de tout type de source T vers tout type de retour TResult.

/// <summary>
/// Creates a GetOrRefreshCache function with encapsulated MemoryCache.
/// </summary>
/// <typeparam name="T">The type of inbound objects to cache.</typeparam>
/// <typeparam name="TResult">How the objects will be serialized to cache and returned.</typeparam>
/// <param name="cacheName">The name of the cache.</param>
/// <param name="valueFactory">The factory for storing values.</param>
/// <param name="keyFactory">An optional factory to choose cache keys.</param>
/// <returns>A function to get or refresh from cache.</returns>
public static Func<T, TResult> GetOrRefreshCacheFactory<T, TResult>(string cacheName, Func<T, TResult> valueFactory, Func<T, string> keyFactory = null) {
    var getKey = keyFactory ?? (obj => obj.GetHashCode().ToString());
    var cache = new MemoryCache(cacheName);
    // Thread-safe lazy cache
    TResult getOrRefreshCache(T obj) {
        var key = getKey(obj);
        var newValue = new Lazy<TResult>(() => valueFactory(obj));
        var value = (Lazy<TResult>) cache.AddOrGetExisting(key, newValue, ObjectCache.InfiniteAbsoluteExpiration);
        return (value ?? newValue).Value;
    }
    return getOrRefreshCache;
}

Usage

/// <summary>
/// Get a JSON object from cache or serialize it if it doesn't exist yet.
/// </summary>
private static readonly Func<object, string> GetJson =
    GetOrRefreshCacheFactory<object, string>("json-cache", JsonConvert.SerializeObject);


var json = GetJson(new { foo = "bar", yes = true });
1
cchamberlain

Voici une solution simple comme méthode d'extension MemoryCache.

 public static class MemoryCacheExtensions
 {
     public static T LazyAddOrGetExitingItem<T>(this MemoryCache memoryCache, string key, Func<T> getItemFunc, DateTimeOffset absoluteExpiration)
     {
         var item = new Lazy<T>(
             () => getItemFunc(),
             LazyThreadSafetyMode.PublicationOnly // Do not cache lazy exceptions
         );

         var cachedValue = memoryCache.AddOrGetExisting(key, item, absoluteExpiration) as Lazy<T>;

         return (cachedValue != null) ? cachedValue.Value : item.Value;
     }
 }

Et testez-le comme description d'utilisation.

[TestMethod]
[TestCategory("MemoryCacheExtensionsTests"), TestCategory("UnitTests")]
public void MemoryCacheExtensions_LazyAddOrGetExitingItem_Test()
{
    const int expectedValue = 42;
    const int cacheRecordLifetimeInSeconds = 42;

    var key = "lazyMemoryCacheKey";
    var absoluteExpiration = DateTimeOffset.Now.AddSeconds(cacheRecordLifetimeInSeconds);

    var lazyMemoryCache = MemoryCache.Default;

    #region Cache warm up

    var actualValue = lazyMemoryCache.LazyAddOrGetExitingItem(key, () => expectedValue, absoluteExpiration);
    Assert.AreEqual(expectedValue, actualValue);

    #endregion

    #region Get value from cache

    actualValue = lazyMemoryCache.LazyAddOrGetExitingItem(key, () => expectedValue, absoluteExpiration);
    Assert.AreEqual(expectedValue, actualValue);

    #endregion
}
0
Oleg