Синхронизированный IEnumerator <T> - PullRequest
3 голосов
/ 11 апреля 2010

Я собираю собственный класс SynchronizedCollection<T>, чтобы я мог иметь синхронизированную коллекцию Observable для своего приложения WPF. Синхронизация обеспечивается через ReaderWriterLockSlim, который по большей части прост в применении. Случай, с которым я столкнулся, заключается в том, как обеспечить потокобезопасное перечисление коллекции. Я создал собственный IEnumerator<T> вложенный класс, который выглядит следующим образом:

    private class SynchronizedEnumerator : IEnumerator<T>
    {
        private SynchronizedCollection<T> _collection;
        private int _currentIndex;

        internal SynchronizedEnumerator(SynchronizedCollection<T> collection)
        {
            _collection = collection;
            _collection._lock.EnterReadLock();
            _currentIndex = -1;
        }

        #region IEnumerator<T> Members

        public T Current { get; private set;}

        #endregion

        #region IDisposable Members

        public void Dispose()
        {
            var collection = _collection;
            if (collection != null)
                collection._lock.ExitReadLock();

            _collection = null;
        }

        #endregion

        #region IEnumerator Members

        object System.Collections.IEnumerator.Current
        {
            get { return Current; }
        }

        public bool MoveNext()
        {
            var collection = _collection;
            if (collection == null)
                throw new ObjectDisposedException("SynchronizedEnumerator");

            _currentIndex++;
            if (_currentIndex >= collection.Count)
            {
                Current = default(T);
                return false;
            }

            Current = collection[_currentIndex];
            return true;
        }

        public void Reset()
        {
            if (_collection == null)
                throw new ObjectDisposedException("SynchronizedEnumerator");

            _currentIndex = -1;
            Current = default(T);
        }

        #endregion
    }

Однако меня беспокоит то, что, если перечислитель не удален, блокировка никогда не будет снята. В большинстве случаев это не проблема, так как foreach должен правильно вызывать Dispose. Однако может возникнуть проблема, если потребитель получит явный экземпляр Enumerator. Является ли мой единственный вариант документирования класса с помощью средства реализации caveat, напоминающего потребителю о необходимости вызова Dispose при явном использовании Enumerator или есть способ безопасно снять блокировку во время финализации? Я думаю, что нет, так как финализатор даже не работает в том же потоке, но мне было любопытно, есть ли другие способы улучшить это.


EDIT

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

Ответы [ 4 ]

3 голосов
/ 11 апреля 2010

Вы правы, это проблема. Финализатор бесполезен, он запустится слишком поздно, чтобы быть полезным. Во всяком случае, код должен был сильно заблокироваться до этого. К сожалению, у вас нет возможности определить разницу между foreach, вызывающим члены MoveNext / Current, или клиентским кодом, использующим их явно.

Не исправить, не делай этого. Microsoft тоже этого не делала, у них было много причин вернуться в .NET 1.x. Единственный реальный потокобезопасный итератор, который вы можете создать, - это тот, который создает копию объекта коллекции в методе GetEnumerator (). Итератор, выходящий из синхронизации с коллекцией, тоже не радует.

2 голосов
/ 11 апреля 2010

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

Обычно хорошей идеей является дублирование общих шаблонов - представляйте перечисляемую коллекцию с IEnumerable<T>, которая удаляется, когда вы закончите с ней, - но дополнительный ингредиент для снятия блокировки делает большим Разница, к сожалению.

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

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

public class SomeCollection<T>
{
    // ...

    public void EnumerateInLock(Action<IEnumerable<T>> action) ...

    // ...
}

То есть, когда пользователь этой коллекции хочет ее перечислить, он делает это:

someCollection.EnumerateInLock(e =>
    {
        foreach (var item in e)
        {
            // blah
        }
    });

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

Реализация метода EnumerateInLock будет выглядеть следующим образом:

public void EnumerateInLock(Action<IEnumerable<T>> action)
{
    var e = new EnumeratorImpl(this);

    try
    {
        _lock.EnterReadLock();
        action(e);
    }
    finally
    {
        e.Dispose();
        _lock.ExitReadLock();
    }
}

Обратите внимание, как EnumeratorImpl (который не нуждается в отдельном коде блокировки) всегда располагается до выхода из блокировки. После удаления он выдает ObjectDisposedException в ответ на любой вызов метода (кроме Dispose, который игнорируется.)

Это означает, что даже при попытке злоупотребления интерфейсом:

IEnumerable<C> keepForLater = null;
someCollection.EnumerateInLock(e => keepForLater = e);

foreach (var item in keepForLater)
{
    // aha!
}

Это будет всегда бросать, а не таинственно проваливаться иногда в зависимости от времени.

Использование метода, который принимает делегата, подобного этому, является общей техникой для управления временем жизни ресурса, обычно используемым в Лиспе и других динамических языках, и, хотя он менее гибок, чем реализация IDisposable, эта пониженная гибкость часто является благословением: снимает беспокойство о клиентах, "забывающих избавиться".

Обновление

Из вашего комментария я вижу, что вам нужно иметь возможность передать ссылку на коллекцию в существующую структуру пользовательского интерфейса, которая, следовательно, будет иметь возможность использовать обычный интерфейс для коллекции, то есть непосредственно получить IEnumerable<T> от этого и можно доверять, чтобы убрать это быстро. В каком случае зачем волноваться? Доверьтесь инфраструктуре пользовательского интерфейса для обновления пользовательского интерфейса и быстрого удаления коллекции.

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

Заманчиво (примерно на наносекунду) предложить вам использовать простое правило: если коллекция меньше некоторого порога, сделайте копию, в противном случае сделайте это в своем первоначальном виде; выберите реализацию динамически. Таким образом, вы получите оптимальную производительность - установите порог (экспериментально) так, чтобы копия была дешевле, чем удержание блокировки. Однако я бы всегда дважды (или миллиард раз) думал о таких «умных» идеях в многопоточном коде, потому что что, если где-то есть злоупотребление перечислителем? Если вы забудете утилизировать его, вы не увидите проблемы , если это не большая коллекция ... Рецепт скрытых ошибок. Не ходи туда!

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

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

1 голос
/ 11 апреля 2010

Я должен был сделать это недавно. То, как я это сделал, заключалось в том, чтобы абстрагировать его так, чтобы был внутренний объект (ссылка), содержащий и фактический список / массив и счетчик (и реализацию GetEnumerator(); тогда я могу сделать перечисление без блокировок, поточно-ориентированное, имея:

public IEnumerator<T> GetEnumerator() { return inner.GetEnumerator();}

Add и т. Д. Необходимо синхронизировать, но они изменяют inner ссылку (поскольку обновления ссылок являются атомарными, вам не нужно синхронизировать GetEnumerator() ). Это означает, что любой перечислитель вернет столько элементов, сколько было при создании перечислителя .

Конечно, помогает то, что мой сценарий был прост, и мой список был Add только ... если вам нужна поддержка mutate / remove, тогда это намного сложнее.

1 голос
/ 11 апреля 2010

В обязательной реализации IDisposable вы создаете защищенный метод Dispose(bool managed), который всегда удаляет неуправляемые ресурсы, которые вы используете. Вызвав защищенный метод Dispose(false) из финализатора, вы удалите блокировку как требуется . Блокировка управляется, вы будете ее использовать только при вызове Dispose(true), где true означает, что управляемые объекты необходимо удалить. В противном случае, когда public Dispose() вызывается явно, он вызывает защищенный Dispose(true), а также GC.SuppressFinalize(this), чтобы предотвратить запуск финализатора (потому что больше нечего утилизировать).

Поскольку вы никогда не знаете, когда пользователь завершил работу с перечислителем, у вас нет другого выбора, кроме документирования того, что пользователь должен избавиться от объекта. Возможно, вы захотите предложить пользователю использовать конструкцию using(){ ... }, которая автоматически удаляет объект по завершении.

...