Ориентированные на многопотоковое исполнение библиотеки кэша для.NET

Фон:

Я поддерживаю несколько приложений Winforms и библиотек классов, которые или могли или уже извлечь выгоду из кэширования. Я также знаю о Блоке Программы кэширования и Системе. Сеть. Кэширование пространства имен (который, от то, что я собрал, совершенно в порядке для использования вне ASP.NET).

Я нашел, что, хотя оба из вышеупомянутых классов технически "ориентированы на многопотоковое исполнение" в том смысле, что отдельные методы синхронизируются, они, действительно кажется, не разработаны особенно хорошо для многопоточных сценариев. А именно, они не реализуют a GetOrAdd метод, подобный тому в новом ConcurrentDictionary класс в.NET 4.0.

Я полагаю, что такой метод примитив для функциональности кэширования/поиска, и очевидно разработчики Framework поняли это также - вот почему, методы существуют в параллельных наборах. Однако кроме того, что я еще не использую.NET 4.0 в производственных приложениях, словарь не является законченным кэшем - это не имеет функций как истечения, персистентное/распределенное устройство хранения данных, и т.д.


Почему это важно:

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

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

  • Не пытайтесь сделать операцию атомарной вообще, и рискнуть данными, загружаемыми дважды (и возможно иметь два различных потока, воздействующие на различные копии);

  • Сериализируйте доступ к кэшу, что означает блокировать весь кэш только для загрузки единственного объекта;

  • Начните изобретать велосипед только для получения нескольких дополнительных методов.


Разъяснение: временная шкала в качестве примера

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

00:00 - Start loading Dataset 1
00:10 - Start loading Dataset 2
00:19 - User asks for Dataset 2

В вышеупомянутом случае, если мы не используем вида синхронизации, пользователь должен ожидать полные 10 секунды данных, которые будут доступны через 1 секунду, потому что код будет видеть, что объект еще не загружается в кэш и попытку перезагрузить его.

00:00 - Start loading Dataset 1
00:10 - Start loading Dataset 2
00:11 - User asks for Dataset 1

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


Вопрос:

Там кто-либо кэширует библиотеки для.NET (пред4.0), которые действительно реализуют такие атомарные операции, как можно было бы ожидать от ориентированного на многопотоковое исполнение кэша?

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

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

31
задан Aaronaught 24 February 2010 в 23:56
поделиться

2 ответа

Похоже, что параллельные коллекции .NET 4.0 используют новые примитивы синхронизации, которые вращаются перед переключением контекста, в случае быстрого освобождения ресурса. Таким образом, они все еще блокируются, просто более оппортунистическим способом. Если вы считаете, что логика получения данных короче, чем временной интервал, то, похоже, это будет очень полезно. Но вы упомянули сеть, что заставляет меня думать, что это не применимо.

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

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

Пример стратегии, если ваш набор данных состоит из элементов, которые имеют числовые идентификаторы, и вы хотите разделить кэш на 10 регионов, вы можете (mod 10) по идентификатору определить, в каком регионе они находятся. Вы будете хранить массив из 10 объектов для блокировки. Весь код может быть написан для переменного числа регионов, которое может быть задано через конфигурацию или определено при запуске приложения в зависимости от общего числа объектов, которые вы прогнозируете/намерены кэшировать.

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

Обновление (по комментарию): Ну, это было весело. Я думаю, что нижеприведенный вариант - это примерно настолько тонкая блокировка, на которую можно надеяться, не сходя с ума (или поддерживая/синхронизируя словарь блокировок для каждого ключа кэша). Я не тестировал это, так что, возможно, есть ошибки, но идея должна быть наглядной. Отслеживать список запрошенных идентификаторов, а затем использовать его, чтобы решить, нужно ли вам получить элемент самостоятельно, или нужно просто подождать, пока завершится предыдущий запрос. Ожидание (и вставка в кэш) синхронизировано с блокировкой и сигнализацией узкоспециализированных потоков с помощью Wait и PulseAll. Доступ к запрашиваемому списку идентификаторов синхронизируется с помощью тесно скопированного ReaderWriterLockSlim.

Это кэш только для чтения. Если вы выполняете создание/обновление/удаление, вам придется убедиться, что вы удаляете идентификаторы из requestedIds после их получения (перед вызовом Monitor.PulseAll(_cache) вы захотите добавить еще одну try..finally и получить блокировку записи _requestedIdsLock). Кроме того, при создании/обновлении/удалении самым простым способом управления кэшем будет простое удаление существующего элемента из _cache, если/когда основная операция создания/обновления/удаления завершится успешно.

(Упс, см. обновление 2 ниже.)

public class Item 
{
    public int ID { get; set; }
}

public class AsyncCache
{
    protected static readonly Dictionary<int, Item> _externalDataStoreProxy = new Dictionary<int, Item>();

    protected static readonly Dictionary<int, Item> _cache = new Dictionary<int, Item>();

    protected static readonly HashSet<int> _requestedIds = new HashSet<int>();
    protected static readonly ReaderWriterLockSlim _requestedIdsLock = new ReaderWriterLockSlim();

    public Item Get(int id)
    {
        // if item does not exist in cache
        if (!_cache.ContainsKey(id))
        {
            _requestedIdsLock.EnterUpgradeableReadLock();
            try
            {
                // if item was already requested by another thread
                if (_requestedIds.Contains(id))
                {
                    _requestedIdsLock.ExitUpgradeableReadLock();
                    lock (_cache)
                    {
                        while (!_cache.ContainsKey(id))
                            Monitor.Wait(_cache);

                        // once we get here, _cache has our item
                    }
                }
                // else, item has not yet been requested by a thread
                else
                {
                    _requestedIdsLock.EnterWriteLock();
                    try
                    {
                        // record the current request
                        _requestedIds.Add(id);
                        _requestedIdsLock.ExitWriteLock();
                        _requestedIdsLock.ExitUpgradeableReadLock();

                        // get the data from the external resource
                        #region fake implementation - replace with real code
                        var item = _externalDataStoreProxy[id];
                        Thread.Sleep(10000);
                        #endregion

                        lock (_cache)
                        {
                            _cache.Add(id, item);
                            Monitor.PulseAll(_cache);
                        }
                    }
                    finally
                    {
                        // let go of any held locks
                        if (_requestedIdsLock.IsWriteLockHeld)
                            _requestedIdsLock.ExitWriteLock();
                    }
                }
            }
            finally
            {
                // let go of any held locks
                if (_requestedIdsLock.IsUpgradeableReadLockHeld)
                    _requestedIdsLock.ExitReadLock();
            }
        }

        return _cache[id];
    }

    public Collection<Item> Get(Collection<int> ids)
    {
        var notInCache = ids.Except(_cache.Keys);

        // if some items don't exist in cache
        if (notInCache.Count() > 0)
        {
            _requestedIdsLock.EnterUpgradeableReadLock();
            try
            {
                var needToGet = notInCache.Except(_requestedIds);

                // if any items have not yet been requested by other threads
                if (needToGet.Count() > 0)
                {
                    _requestedIdsLock.EnterWriteLock();
                    try
                    {
                        // record the current request
                        foreach (var id in ids)
                            _requestedIds.Add(id);

                        _requestedIdsLock.ExitWriteLock();
                        _requestedIdsLock.ExitUpgradeableReadLock();

                        // get the data from the external resource
                        #region fake implementation - replace with real code
                        var data = new Collection<Item>();
                        foreach (var id in needToGet)
                        {
                            var item = _externalDataStoreProxy[id];
                            data.Add(item);
                        }
                        Thread.Sleep(10000);
                        #endregion

                        lock (_cache)
                        {
                            foreach (var item in data)
                                _cache.Add(item.ID, item);

                            Monitor.PulseAll(_cache);
                        }
                    }
                    finally
                    {
                        // let go of any held locks
                        if (_requestedIdsLock.IsWriteLockHeld)
                            _requestedIdsLock.ExitWriteLock();
                    }
                }

                if (requestedIdsLock.IsUpgradeableReadLockHeld)
                    _requestedIdsLock.ExitUpgradeableReadLock();

                var waitingFor = notInCache.Except(needToGet);
                // if any remaining items were already requested by other threads
                if (waitingFor.Count() > 0)
                {
                    lock (_cache)
                    {
                        while (waitingFor.Count() > 0)
                        {
                            Monitor.Wait(_cache);
                            waitingFor = waitingFor.Except(_cache.Keys);
                        }

                        // once we get here, _cache has all our items
                    }
                }
            }
            finally
            {
                // let go of any held locks
                if (_requestedIdsLock.IsUpgradeableReadLockHeld)
                    _requestedIdsLock.ExitReadLock();
            }
        }

        return new Collection<Item>(ids.Select(id => _cache[id]).ToList());
    }
}

Обновление 2:

Я неправильно понял поведение UpgradeableReadLock... только один поток в одно время может держать UpgradeableReadLock. Поэтому вышеприведенный пример должен быть доработан, чтобы изначально захватывать только блокировки чтения, а при добавлении элементов в _requestedIds полностью отказываться от них и приобретать полноценную блокировку записи.

3
ответ дан 27 November 2019 в 22:52
поделиться

Наконец, благодаря диалогу в комментариях, мы нашли работоспособное решение этой проблемы. Я создал оболочку, которая представляет собой частично реализованный абстрактный базовый класс, который использует любую стандартную библиотеку кеша в качестве резервного кеша (просто необходимо реализовать Contains , Get , Положите методы и Удалить ). В настоящий момент я использую для этого блок приложения EntLib Caching Application, и потребовалось время, чтобы его запустить и запустить, потому что некоторые аспекты этой библиотеки ... ну ... не настолько хорошо продуманы.

В любом случае, общий код сейчас близок к 1 КБ строк, поэтому я не собираюсь публиковать здесь все, но основная идея такова:

  1. Перехватить все вызовы к Get , Положить / добавить и Удалить методы.

  2. Вместо добавления исходного элемента добавьте элемент «запись», который содержит ManualResetEvent в дополнение к свойству Value . Согласно некоторым советам, данным мне по ранее заданному сегодня вопросу, запись реализует защелку обратного отсчета, которая увеличивается при каждом получении записи и уменьшается при ее освобождении. И загрузчик, и все последующие операции поиска участвуют в защелке обратного отсчета, поэтому, когда счетчик достигает нуля, данные гарантированно доступны, а событие ManualResetEvent уничтожается в целях экономии ресурсов.

  3. Когда требуется отложенная загрузка записи, она сразу же создается и добавляется в резервный кэш, при этом событие находится в несигнальном состоянии. Последующие вызовы либо нового метода GetOrAdd , либо перехваченных методов Get найдут эту запись и либо дождутся события (если событие существует), либо немедленно вернут связанное значение (если событие не существует).

  4. Метод Put добавляет запись без события; они выглядят так же, как записи, для которых отложенная загрузка уже завершена.

  5. Поскольку GetOrAdd по-прежнему реализует Get , за которым следует необязательный Put ], этот метод синхронизируется (сериализуется) с Put и Удалить методы , но только для добавления неполной записи, а не на все время отложенной загрузки. Методы Get не сериализованы; фактически весь интерфейс работает как автоматическая блокировка чтения и записи.

Это все еще незавершенная работа, но я прогнал ее через дюжину юнит-тестов, и, похоже, она работает. Он работает правильно для обоих сценариев, описанных в вопросе. Другими словами:

  • Вызов длительной отложенной загрузки ( GetOrAdd ) для ключа X (моделируется с помощью Thread.Sleep ), который занимает 10 секунд, за которым следует еще один GetOrAdd для того же ключа X в другом потоке ровно через 9 секунд, в результате оба потока получают правильные данные одновременно (10 секунд от T 0 ). Нагрузки не дублируются.

  • Немедленная загрузка значения для ключа X , затем запуск длительной отложенной загрузки для ключа Y , затем запрос ключа X в другом потоке ( до завершения Y ) немедленно возвращает значение для X . Блокирующие вызовы привязаны к соответствующему ключу.

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

В целом я очень доволен этим. Я все еще хотел бы, чтобы была библиотека, которая делала бы это для меня, но я полагаю, если вы хотите, чтобы что-то было сделано правильно ... ну, вы знаете.

0
ответ дан 27 November 2019 в 22:52
поделиться
Другие вопросы по тегам:

Похожие вопросы: