Производительность в отношении кешированной реализации IEnumerable‹T›

[ИЗМЕНИТЬ]

Новая Reactive Framework решает описанную ниже проблему, используя расширение System.Linq.EnumerableEx.MemoizeAll(). метод.

Внутри MemoizeAll() используется System.Linq.EnumerableEx.MemoizeAllEnumerable<T> (найденный в сборке System.Interactive), который похож на мой ThreadSafeCachedEnumerable<T> (вроде).

Вот ужасно надуманный пример, который печатает содержимое Enumerable (номера 1-10) очень медленно, а затем быстро печатает содержимое во второй раз (потому что он кэширует значения):

// Create an Enumerable<int> containing numbers 1-10, using Thread.Sleep() to simulate work
var slowEnum = EnumerableEx.Generate(1, currentNum => (currentNum <= 10), currentNum => currentNum, previousNum => { Thread.Sleep(250); return previousNum + 1; });

// This decorates the slow enumerable with one that will cache each value.
var cachedEnum = slowEnum.MemoizeAll();

// Print the numbers
foreach (var num in cachedEnum.Repeat(2))
{
    Console.WriteLine(num);
}

[/РЕДАКТИРОВАТЬ]

Здравствуйте, гуру многопоточности!

Я создал класс ThreadSafeCachedEnumerable, намереваясь повысить производительность при повторном использовании длительных запросов. Идея заключалась в том, чтобы получить перечислитель из IEnumerable и добавлять элементы в кеш при каждом вызове MoveNext(). Ниже приведена моя текущая реализация:

/// <summary>
/// Wraps an IEnumerable&lt;T&gt; and provides a thread-safe means of caching the values."/>
/// </summary>
/// <typeparam name="T"></typeparam>
class ThreadSafeCachedEnumerable<T> : IEnumerable<T>
{
    // An enumerator from the original IEnumerable<T>
    private IEnumerator<T> enumerator;

    // The items we have already cached (from this.enumerator)
    private IList<T> cachedItems = new List<T>();

    public ThreadSafeCachedEnumerable(IEnumerable<T> enumerable)
    {
        this.enumerator = enumerable.GetEnumerator();
    }

    #region IEnumerable<T> Members

    public IEnumerator<T> GetEnumerator()
    {
        // The index into the sequence
        int currentIndex = 0;

        // We will break with yield break 
        while (true)
        {
            // The currentIndex will never be decremented,
            // so we can check without locking first
            if (currentIndex < this.cachedItems.Count)
            {
                var current = this.cachedItems[currentIndex];
                currentIndex += 1;
                yield return current;
            }
            else
            {
                // If !(currentIndex < this.cachedItems.Count),
                // we need to synchronize access to this.enumerator
                lock (enumerator)
                {
                    // See if we have more cached items ...
                    if (currentIndex < this.cachedItems.Count)
                    {
                        var current = this.cachedItems[currentIndex];
                        currentIndex += 1;
                        yield return current;
                    }
                    else
                    {
                        // ... otherwise, we'll need to get the next item from this.enumerator.MoveNext()
                        if (this.enumerator.MoveNext())
                        {
                            // capture the current item and cache it, then increment the currentIndex
                            var current = this.enumerator.Current;
                            this.cachedItems.Add(current);
                            currentIndex += 1;
                            yield return current;
                        }
                        else
                        {
                            // We reached the end of the enumerator - we're done
                            yield break;
                        }
                    }
                }
            }
        }
    }

    #endregion

    #region IEnumerable Members

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return this.GetEnumerator();
    }

    #endregion
}


Я просто «блокирую (this.enumerator)», когда в кеше больше нет элементов, на тот случай, если другой поток собирается добавить еще один элемент (я предполагаю, что вызов MoveNext() для this.enumerator из двух потоков плохая идея).

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

Спасибо!


person Charles    schedule 06.07.2009    source источник
comment
Я не вижу ни MemoizeAll, ни MemoizeAllEnumerable в кодовой базе Rx. При этом ваш ThreadSafeCachedEnumerable забыл вызвать dispose() для исходного перечислителя, который является IDisposable. Обычно за это отвечают методы foreach или Linq, но, поскольку теперь вы их получили, вы должны нести ответственность за их удаление.   -  person KFL    schedule 02.04.2015


Ответы (2)


Пара рекомендаций:

  1. В настоящее время общепринятой практикой является не возлагать ответственность за блокировку на классы-контейнеры. Например, кто-то, вызывающий ваш кэшированный перечислитель, может также захотеть предотвратить добавление новых записей в контейнер во время перечисления, что означает, что блокировка будет выполняться дважды. Поэтому лучше возложить эту ответственность на вызывающую сторону.
  2. Кэширование зависит от того, что перечислитель всегда возвращает элементы по порядку, что не гарантируется. Лучше использовать Dictionary или HashSet. Точно так же элементы могут быть удалены между вызовами, что сделает кэш недействительным.
  3. Вообще не рекомендуется устанавливать блокировки на общедоступные объекты. Это включает в себя обернутый перечислитель. Исключения возможны, например, когда вы абсолютно уверены, что вы абсолютно уверены, что вы единственный экземпляр, содержащий ссылку на класс контейнера, который вы перечисляете. Это также в значительной степени аннулирует мои возражения по пункту 2.
person Michiel Buddingh    schedule 06.07.2009
comment
+1 что в настоящее время общепринятой практикой является не возлагать ответственность за блокировку на классы контейнеров. Желаю всем получить эту памятку. - person Frank Schwieterman; 06.07.2009
comment
Это хорошие рекомендации - вот мои мысли: 1) Это слишком усложнило бы мои задачи. Я просто хочу кэшировать результаты дорогостоящих проекций LINQ, где мне может понадобиться только несколько результатов. Ленивый загружаемый список, по сути, только без возможности доступа к элементу по индексу. Я бы согласился с вами для обычных контейнеров, хотя. 2) Для моих целей вызывающий абонент должен понимать это как предостережение. 3) Я не могу представить, чтобы IEnumerable содержал ссылку на один из своих IEnumerators, но я полагаю, что это возможно. Спасибо за предложения. - person Charles; 06.07.2009
comment
Общепринятая практика, а? Объясняет ли это новое пространство имен System.Collections.Concurrent в .NET 4.0? - person Dan Tao; 20.11.2009
comment
Подробно не изучал 4.0, но на первый взгляд, это новое пространство имен кажется хорошим признаком того, что «потокобезопасные» контейнеры являются исключением, а не правилом. Но вы, наверное, правы, что «общепринятая практика» — смелое выражение. - person Michiel Buddingh; 20.11.2009

Блокировка в .NET обычно происходит очень быстро (если нет разногласий). Идентифицировала ли профилирование блокировку как источник проблемы с производительностью? Сколько времени занимает вызов MoveNext в базовом перечислителе?

Кроме того, код в его нынешнем виде не является потокобезопасным. Вы не можете безопасно вызывать this.cachedItems[currentIndex] в одном потоке (в if (currentIndex < this.cachedItems.Count)), одновременно вызывая this.cachedItems.Add(current) в другом. Из документации List(T): "A List(T) может одновременно поддерживать несколько считывателей, если коллекция не изменена». Чтобы быть потокобезопасным, вам необходимо защитить весь доступ к this.cachedItems блокировкой (если есть шанс, что один или несколько потоков могут изменить его).

person Bradley Grainger    schedule 06.07.2009
comment
Это верное замечание, но знаете ли вы, может ли быть выброшено исключение? Идея отсутствия блокировки заключалась в том, что я мог считать, что список только увеличивается, а индекс увеличивается, поэтому я мог обойти необходимость блокировки, если (currentIndex ‹ this.cachedItems.Count) - иначе я мог бы заблокировать и дважды проверить его . Я поигрался с его профилированием с помощью секундомера - повторение 20 миллионов элементов в первый раз добавляет еще 2 секунды (полагаю, это незначительно). - person Charles; 06.07.2009
comment
Главное, о чем я бы беспокоился, это другой поток, изменяющий размер внутреннего массива списка (в Add()), в то время как поток чтения использует индексатор для извлечения элемента. Кажется возможным, что он может вернуть значение по умолчанию (T) или создать исключение ArgumentOutOfRangeException. Конечно, все это зависит от точной реализации List‹T›, поэтому лучшее, что я могу сказать, это то, что поведение не определено. (Даже если Reflector покажет вам, что он безопасен, кто знает, может ли он измениться в .NET 4.0, представив незаметную и трудно обнаруживаемую ошибку?) - person Bradley Grainger; 07.07.2009