Шаблон блокировки для правильного использования .NET MemoryCache

Я предполагаю, что этот код имеет проблемы concurrency:

const string CacheKey = "CacheKey";
static string GetCachedData()
{
    string expensiveString =null;
    if (MemoryCache.Default.Contains(CacheKey))
    {
        expensiveString = MemoryCache.Default[CacheKey] as string;
    }
    else
    {
        CacheItemPolicy cip = new CacheItemPolicy()
        {
            AbsoluteExpiration = new DateTimeOffset(DateTime.Now.AddMinutes(20))
        };
        expensiveString = SomeHeavyAndExpensiveCalculation();
        MemoryCache.Default.Set(CacheKey, expensiveString, cip);
    }
    return expensiveString;
}

Причина проблемы concurrency заключается в том, что несколько потоков могут получить нулевой ключ, а затем попытаться вставить данные в кеш.

Каким будет самый короткий и самый чистый способ сделать этот код concurrency доказательством? Мне нравится следовать хорошему шаблону в моем коде, связанном с кешем. Ссылка на онлайн-статью будет большой помощью.

UPDATE:

Я придумал этот код, основанный на ответе @Scott Chamberlain. Может ли кто-нибудь найти какую-либо производительность или concurrency проблему с этим? Если это сработает, это сэкономит много строк кода и ошибок.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Runtime.Caching;

namespace CachePoc
{
    class Program
    {
        static object everoneUseThisLockObject4CacheXYZ = new object();
        const string CacheXYZ = "CacheXYZ";
        static object everoneUseThisLockObject4CacheABC = new object();
        const string CacheABC = "CacheABC";

        static void Main(string[] args)
        {
            string xyzData = MemoryCacheHelper.GetCachedData<string>(CacheXYZ, everoneUseThisLockObject4CacheXYZ, 20, SomeHeavyAndExpensiveXYZCalculation);
            string abcData = MemoryCacheHelper.GetCachedData<string>(CacheABC, everoneUseThisLockObject4CacheXYZ, 20, SomeHeavyAndExpensiveXYZCalculation);
        }

        private static string SomeHeavyAndExpensiveXYZCalculation() {return "Expensive";}
        private static string SomeHeavyAndExpensiveABCCalculation() {return "Expensive";}

        public static class MemoryCacheHelper
        {
            public static T GetCachedData<T>(string cacheKey, object cacheLock, int cacheTimePolicyMinutes, Func<T> GetData)
                where T : class
            {
                //Returns null if the string does not exist, prevents a race condition where the cache invalidates between the contains check and the retreival.
                T cachedData = MemoryCache.Default.Get(cacheKey, null) as T;

                if (cachedData != null)
                {
                    return cachedData;
                }

                lock (cacheLock)
                {
                    //Check to see if anyone wrote to the cache while we where waiting our turn to write the new value.
                    cachedData = MemoryCache.Default.Get(cacheKey, null) as T;

                    if (cachedData != null)
                    {
                        return cachedData;
                    }

                    //The value still did not exist so we now write it in to the cache.
                    CacheItemPolicy cip = new CacheItemPolicy()
                    {
                        AbsoluteExpiration = new DateTimeOffset(DateTime.Now.AddMinutes(cacheTimePolicyMinutes))
                    };
                    cachedData = GetData();
                    MemoryCache.Default.Set(cacheKey, cachedData, cip);
                    return cachedData;
                }
            }
        }
    }
}

Ответ 1

Это моя вторая итерация кода. Поскольку MemoryCache является потокобезопасным, вам не нужно блокировать начальное чтение, вы можете просто прочитать, и если кеш возвращает значение null, выполните проверку блокировки, чтобы проверить, нужно ли создавать строку. Это значительно упрощает код.

const string CacheKey = "CacheKey";
static readonly object cacheLock = new object();
private static string GetCachedData()
{

    //Returns null if the string does not exist, prevents a race condition where the cache invalidates between the contains check and the retreival.
    var cachedString = MemoryCache.Default.Get(CacheKey, null) as string;

    if (cachedString != null)
    {
        return cachedString;
    }

    lock (cacheLock)
    {
        //Check to see if anyone wrote to the cache while we where waiting our turn to write the new value.
        cachedString = MemoryCache.Default.Get(CacheKey, null) as string;

        if (cachedString != null)
        {
            return cachedString;
        }

        //The value still did not exist so we now write it in to the cache.
        var expensiveString = SomeHeavyAndExpensiveCalculation();
        CacheItemPolicy cip = new CacheItemPolicy()
                              {
                                  AbsoluteExpiration = new DateTimeOffset(DateTime.Now.AddMinutes(20))
                              };
        MemoryCache.Default.Set(CacheKey, expensiveString, cip);
        return expensiveString;
    }
}

EDIT: приведенный ниже код не нужен, но я хотел оставить его, чтобы показать оригинальный метод. Он может быть полезен для будущих посетителей, которые используют другую коллекцию, которая имеет потокобезопасные чтения, но безопасные записи, отличные от потоков (почти все классы в пространстве имен System.Collections аналогичны).

Вот как я сделал бы это, используя ReaderWriterLockSlim для защиты доступа. Вам нужно сделать своего рода " Double Checked Locking", чтобы узнать, не создал ли кто-либо другой элемент кэширования, пока мы, где ожидаем, чтобы сделать блокировку.

const string CacheKey = "CacheKey";
static readonly ReaderWriterLockSlim cacheLock = new ReaderWriterLockSlim();
static string GetCachedData()
{
    //First we do a read lock to see if it already exists, this allows multiple readers at the same time.
    cacheLock.EnterReadLock();
    try
    {
        //Returns null if the string does not exist, prevents a race condition where the cache invalidates between the contains check and the retreival.
        var cachedString = MemoryCache.Default.Get(CacheKey, null) as string;

        if (cachedString != null)
        {
            return cachedString;
        }
    }
    finally
    {
        cacheLock.ExitReadLock();
    }

    //Only one UpgradeableReadLock can exist at one time, but it can co-exist with many ReadLocks
    cacheLock.EnterUpgradeableReadLock();
    try
    {
        //We need to check again to see if the string was created while we where waiting to enter the EnterUpgradeableReadLock
        var cachedString = MemoryCache.Default.Get(CacheKey, null) as string;

        if (cachedString != null)
        {
            return cachedString;
        }

        //The entry still does not exist so we need to create it and enter the write lock
        var expensiveString = SomeHeavyAndExpensiveCalculation();
        cacheLock.EnterWriteLock(); //This will block till all the Readers flush.
        try
        {
            CacheItemPolicy cip = new CacheItemPolicy()
            {
                AbsoluteExpiration = new DateTimeOffset(DateTime.Now.AddMinutes(20))
            };
            MemoryCache.Default.Set(CacheKey, expensiveString, cip);
            return expensiveString;
        }
        finally 
        {
            cacheLock.ExitWriteLock();
        }
    }
    finally
    {
        cacheLock.ExitUpgradeableReadLock();
    }
}

Ответ 2

Я решил эту проблему, используя метод AddOrGetExisting на MemoryCache и использование Lazy initialization.

По сути, мой код выглядит примерно так:

static string GetCachedData(string key, DateTimeOffset offset)
{
    Lazy<String> lazyObject = new Lazy<String>(() => SomeHeavyAndExpensiveCalculationThatReturnsAString());
    var returnedLazyObject = MemoryCache.Default.AddOrGetExisting(key, lazyObject, offset); 
    if (returnedLazyObject == null)
       return lazyObject.Value;
    return ((Lazy<String>) returnedLazyObject).Value;
}

В худшем случае сценарий заключается в том, что вы дважды создаете тот же объект Lazy. Но это довольно тривиально. Использование AddOrGetExisting гарантирует, что вы только когда-нибудь получите один экземпляр объекта Lazy, и вам также будет гарантированно только один раз вызвать дорогостоящий метод инициализации.

Ответ 3

Существует библиотека с открытым исходным кодом [отказ от ответственности: я написал]: LazyCache, что ИМО покрывает ваше требование двумя строками кода

IAppCache cache = new CachingService();
var cachedResults = cache.GetOrAdd("CacheKey", 
  () => SomeHeavyAndExpensiveCalculation());

По умолчанию он встроен в блокировку, поэтому метод кэширования будет выполняться только один раз за пропущенный кеш, и он использует лямбда, чтобы вы могли "получить или добавить" за один раз. По умолчанию оно истекает до 20 минут.

Там даже пакет NuGet;)

Ответ 4

Я предполагаю, что этот код имеет проблемы concurrency:

Собственно, это вполне возможно прекрасно, хотя и с возможным улучшением.

Теперь, в общем, шаблон, в котором мы имеем несколько потоков, устанавливающих общее значение при первом использовании, чтобы не блокировать полученное и заданное значение, может быть:

  • Катастрофический - другой код будет предполагать, что существует только один экземпляр.
  • Ужасно - код, который получает экземпляр, не может допускать только одно (или, возможно, определенное количество) параллельных операций.
  • Ужасно - средства хранения не являются потокобезопасными (например, имеют два потока, добавляющих словарь, и вы можете получить всевозможные неприятные ошибки).
  • Субоптимальный - общая производительность хуже, чем если бы блокировка обеспечивала выполнение только одного потока работы по получению значения.
  • Оптимальный - стоимость наличия нескольких потоков делает избыточную работу меньше, чем затраты на ее предотвращение, тем более, что это может произойти только в течение относительно короткого периода времени.

Однако, учитывая, что MemoryCache может вытеснять записи, тогда:

  • Если это катастрофично иметь более одного экземпляра, тогда MemoryCache - неправильный подход.
  • Если вы должны предотвратить одновременное создание, вы должны сделать это в момент создания.
  • MemoryCache является потокобезопасным с точки зрения доступа к этому объекту, поэтому здесь не проблема.

Конечно, обе эти возможности необходимо учитывать, хотя единственный раз, когда есть два экземпляра одной и той же строки, может быть проблемой, если вы делаете очень конкретные оптимизации, которые здесь не применяются *.

Итак, у нас остались возможности:

  • Дешевле избежать дублирования вызовов на SomeHeavyAndExpensiveCalculation().
  • Дешевле не избегать затрат на дублирование вызовов на SomeHeavyAndExpensiveCalculation().

И работать с этим может быть сложно (действительно, это то, что стоит профилировать, а не предполагать, что вы можете это исправить). Здесь стоит учитывать, что наиболее очевидные способы блокировки при вставке будут препятствовать добавлению всех кэш-памяти, включая те, которые не связаны.

Это означает, что если бы у нас было 50 потоков, пытающихся установить 50 разных значений, тогда нам нужно заставить все 50 потоков ждать друг на друга, даже если они даже не собирались делать то же вычисление.

Таким образом, вы, вероятно, лучше с кодом, который у вас есть, чем с кодом, который позволяет избежать состояния гонки, и если состояние гонки является проблемой, вы, скорее всего, либо будете обращаться с этим где-то еще, либо нужна другая стратегия кэширования, чем та, которая исключает старые записи †.

Единственное, что я бы изменил, это заменить вызов Set() на один на AddOrGetExisting(). Из вышеизложенного должно быть ясно, что, вероятно, это не обязательно, но это позволит собирать вновь полученный предмет, уменьшая общее использование памяти и позволяя более высокое соотношение коллекций с низким поколением и высоким уровнем.

Итак, вы можете использовать двойную блокировку для предотвращения concurrency, но либо concurrency на самом деле не проблема, либо сохранение значений в неправильном ключе, либо двойная блокировка в хранилище не будет быть лучшим способом его решить.

* Если вы знаете, что существует только один из множества строк, вы можете оптимизировать сопоставления равенства, то есть единственное время, когда две копии строки могут быть неправильными, а не просто субоптимальными, но вы хотите чтобы делать очень разные типы кеширования, чтобы это имело смысл. Например. тип XmlReader делает внутренне.

† Довольно вероятно либо тот, который хранится неопределенно, либо тот, который использует слабые ссылки, поэтому он будет только вытеснять записи, если нет существующих приложений.

Ответ 5

Пример консоли MemoryCache, "Как сохранить/получить простые объекты класса"

Выход после запуска и нажатия Any key кроме Esc:

Сохранение кеша!
Получение из кеша!
Some1
Some2

    class Some
    {
        public String text { get; set; }

        public Some(String text)
        {
            this.text = text;
        }

        public override string ToString()
        {
            return text;
        }
    }

    public static MemoryCache cache = new MemoryCache("cache");

    public static string cache_name = "mycache";

    static void Main(string[] args)
    {

        Some some1 = new Some("some1");
        Some some2 = new Some("some2");

        List<Some> list = new List<Some>();
        list.Add(some1);
        list.Add(some2);

        do {

            if (cache.Contains(cache_name))
            {
                Console.WriteLine("Getting from cache!");
                List<Some> list_c = cache.Get(cache_name) as List<Some>;
                foreach (Some s in list_c) Console.WriteLine(s);
            }
            else
            {
                Console.WriteLine("Saving to cache!");
                cache.Set(cache_name, list, DateTime.Now.AddMinutes(10));                   
            }

        } while (Console.ReadKey(true).Key != ConsoleKey.Escape);

    }