3

我正在组合一个自定义SynchronizedCollection<T>类,以便我可以为我的 WPF 应用程序拥有一个同步的 Observable 集合。同步是通过 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
    }

然而,我担心的是,如果 Enumerator 没有被 Disposed,锁将永远不会被释放。在大多数用例中,这不是问题,因为 foreach 应该正确调用 Dispose。但是,如果消费者检索显式 Enumerator 实例,则可能会出现问题。如果明确使用枚举器,我唯一的选择是使用警告实现者来记录类,提醒消费者调用 Dispose,还是有办法在最终确定期间安全地释放锁?我不这么认为,因为终结器甚至不在同一个线程上运行,但我很好奇是否还有其他方法可以改进这一点。


编辑

在考虑了这一点并阅读了回复(特别感谢 Hans)之后,我认为这绝对是一个坏主意。最大的问题其实不是忘记 Dispose,而是一个悠闲的消费者在枚举的时候制造了死锁。我现在只读取足够长的时间来获取副本并返回副本的枚举器。

4

4 回答 4

3

你是对的,这是个问题。终结器是无用的,它将运行太晚而无用。无论如何,代码应该在此之前严重死锁。不幸的是,您无法区分调用 MoveNext/Current 成员的 foreach 或显式使用它们的客户端代码。

不修复,不要这样做。微软也没有这样做,他们有充分的理由支持 .NET 1.x。您可以创建的唯一真正的线程安全迭代器是在 GetEnumerator() 方法中创建集合对象的副本。迭代器与集合不同步也不是一件好事。

于 2010-04-11T15:34:41.733 回答
2

这对我来说似乎太容易出错了。它鼓励以一种对代码读者来说并不清楚的方式隐式/无声地取出锁的情况,并且可能会误解有关接口的关键事实。

通常,复制常见模式是一个好主意 - 表示一个可枚举的集合,当你完成它时会处理它 - 但不幸IEnumerable<T>的是,取出锁的附加成分会产生很大的不同。

我建议理想的方法是根本不提供线程之间共享的集合的枚举。尝试设计整个系统,使其不再需要。显然,有时这将是一个疯狂的白日梦。

因此,下一个最好的事情是定义一个上下文,其中 anIEnumerable<T>暂时可用,而锁存在:

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

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

    // ...
}

也就是说,当这个集合的用户想要枚举它时,他们这样做:

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

这使得锁的生命周期由作用域(由 lambda 主体表示,工作起来很像一条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!
}

总是会抛出,而不是有时基于时间而神秘地失败。

使用像这样接受委托的方法是管理 Lisp 和其他动态语言中常用的资源生命周期的通用技术,虽然它不如实现灵活IDisposable,但降低灵活性通常是一件好事:它消除了对客户端的担忧“忘记处置”。

更新

从您的评论中,我看到您需要能够将对集合的引用传递给现有的 UI 框架,因此期望能够使用集合的普通接口,即直接从中获取IEnumerable<T>并被信任快速清理它。在这种情况下,何必担心?相信 UI 框架会更新 UI 并快速处理集合。

您唯一的其他现实选择是在请求枚举器时简单地制作集合的副本。这样,只有在制作副本时才需要持有锁。一旦准备好,锁就会被释放。如果集合通常很小,这可能会更有效,因此副本的开销小于由于较短的锁而节省的性能。

建议您使用一个简单的规则是很有诱惑力的(大约一纳秒):如果集合小于某个阈值,则制作副本,否则以您原来的方式进行;动态选择实现。这样您就可以获得最佳性能 - 设置阈值(通过实验),使得副本比持有锁更便宜。但是,对于线程代码中的这种“聪明”想法,我总是三思而后行(或十亿次),因为如果在某处滥用枚举数怎么办?如果您忘记处理它,除非它是一个大集合,否则您不会看到问题......隐藏错误的秘诀。不要去那里!

“公开副本”方法的另一个潜在缺点是,客户无疑会假设,如果一个项目在集合中,它就会暴露给世界,但一旦它从集合中移除,它就会被安全地隐藏起来。现在这将是错误的!UI 线程将获得一个枚举器,然后我的后台线程将从中删除最后一项,然后开始对其进行变异,因为错误地认为因为它已被删除,所以其他人无法看到它。

因此,复制方法要求集合中的每个项目有效地具有自己的同步,大多数编码人员会假设他们可以通过使用集合的同步来缩短此同步。

于 2010-04-11T15:22:14.600 回答
1

在必须使用的IDisposable实现中,您创建了一个受保护的Dispose(bool managed)方法,该方法始终处置您使用的非托管资源。通过从终结器调用受保护的Dispose(false)方法,您将根据需要处理锁。锁是托管的,你只会在Dispose(true)调用时释放它,这true意味着需要释放托管对象。否则,当Dispose()显式调用 public 时,它会调用 protectedDispose(true)GC.SuppressFinalize(this)阻止终结器运行(因为不再需要处理任何东西)。

因为您永远不知道用户何时使用完枚举器,所以您别无选择,只能记录用户必须处置对象。您可能想建议用户使用一种using(){ ... }构造,该构造在完成后会自动处理对象。

于 2010-04-11T14:57:10.280 回答
1

我最近不得不这样做。我这样做的方法是抽象它,以便有一个包含实际列表/数组和计数(以及一个实现)的内部对象(引用)然后可以GetEnumerator()通过以下方式进行无锁、线程安全的枚举:

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

Addetc 需要同步,但它们会更改引用(由于inner引用更新是原子的,因此您不需要synchronize GetEnumerator())。这意味着任何枚举器都将返回与创建枚举器时一样多的项目。

当然,这有助于我的场景很简单,而我的列表Add只是......如果你需要支持 mutate / remove 那就更棘手了。

于 2010-04-11T15:14:19.907 回答