这对于并发集/队列组合看起来是一种合理的方法吗?

时间:2010-09-29 18:05:30

标签: .net multithreading concurrency queue set

更新:正如Brian指出的那样,我最初的想法确实存在并发问题。这有点被ConcurrentDictionary<TKey, TValue>.AddOrUpdate方法的签名所掩盖,它可以让一个懒惰的思想家(比如我自己)相信所有东西 - 集合添加以及队列推送 - 会以某种方式同时发生,原子地(即神奇地)。

回想起来,有这种期望对我来说是愚蠢的。实际上,无论AddOrUpdate的实现如何,都应该很清楚,在我最初的想法中仍然存在竞争条件,正如布莱恩所指出的那样:在添加到集合之前推送到队列,因此以下可能发生一系列事件:

  1. 项目被推送到队列
  2. 从队列中弹出的项目
  3. 项目(未)从集合中删除
  4. 项目已添加到设置
  5. 上述序列会导致集合中的项目不在队列中,从而有效地将该项目列入数据结构中。

    现在,我想了一会儿,我开始认为以下方法可以解决这些问题:

    public bool Enqueue(T item)
    {
        // This should:
        // 1. return true only when the item is first added to the set
        // 2. subsequently return false as long as the item is in the set;
        //    and it will not be removed until after it's popped
        if (_set.TryAdd(item, true))
        {
            _queue.Enqueue(item);
            return true;
        }
    
        return false;
    }
    

    以这种方式构建它,Enqueue调用只发生一次 - 项目在集合中之后。因此,队列中的重复项应该不是问题。而且似乎由于队列操作被设置操作“预订” - 即,只有之后将项目添加到集合中,并且在之前弹出它被从集合中移除 - 上面列出的有问题的事件序列不应该发生。

    人们怎么想?难道这可以解决这个问题吗? (就像Brian一样,我倾向于怀疑自己,并且猜测答案是 No ,我再次错过了一些东西。但是,如果它很容易,那就不会是一个有趣的挑战,对吧?)


    我确实在SO上看到了类似的问题,但令人惊讶的是(考虑到.NET网站的重量程度如何),它们似乎都是针对Java的。

    我本质上需要一个线程安全的set / queue组合类。换句话说,它应该是一个不允许重复的FIFO集合(因此,如果队列中已经存在相同的项,则后续的Enqueue调用将返回false,直到从队列中弹出该项为止。)

    我意识到我可以通过简单的HashSet<T>Queue<T>轻松实现这一点,并锁定所有必要的位置。但是,我有兴趣使用.NET 4.0中的ConcurrentDictionary<TKey, TValue>ConcurrentQueue<T>类来完成它(也可以作为.NET 3.5的Rx扩展的一部分,我正在使用它),我理解为某种无锁集合*。

    我的基本计划是实现这样的集合:

    class ConcurrentSetQueue<T>
    {
        ConcurrentQueue<T> _queue;
        ConcurrentDictionary<T, bool> _set;
    
        public ConcurrentSetQueue(IEqualityComparer<T> comparer)
        {
            _queue = new ConcurrentQueue<T>();
            _set = new ConcurrentDictionary<T, bool>(comparer);
        }
    
        public bool Enqueue(T item)
        {
            // This should:
            // 1. if the key is not present, enqueue the item and return true
            // 2. if the key is already present, do nothing and return false
            return _set.AddOrUpdate(item, EnqueueFirst, EnqueueSecond);
        }
    
        private bool EnqueueFirst(T item)
        {
            _queue.Enqueue(item);
            return true;
        }
    
        private bool EnqueueSecond(T item, bool dummyFlag)
        {
            return false;
        }
    
        public bool TryDequeue(out T item)
        {
            if (_queue.TryDequeue(out item))
            {
                // Another thread could come along here, attempt to enqueue, and
                // fail; however, this seems like an acceptable scenario since the
                // item shouldn't really be considered "popped" until it's been
                // removed from both the queue and the dictionary.
                bool flag;
                _set.TryRemove(item, out flag);
    
                return true;
            }
    
            return false;
        }
    }
    

    我是否正确地想到了这一点?从表面上看,我在上面写的这个基本概念中看不到任何明显的错误。但也许我忽视了一些事情。或者使用带有ConcurrentQueue<T>的{​​{1}}实际上并不是明智的选择,原因是我没有想到。或许其他人已经在某个经过实战验证的图书馆中实现了这个想法,我应该使用它。

    非常感谢有关此主题的任何想法或有用信息!

    *这是否严格准确,我不知道;但性能测试向我表明,它们的表现优于使用锁定许多消费者线程的可比手工收藏。

2 个答案:

答案 0 :(得分:5)

简称是否定,问题中提供的代码不是线程安全的。

MSDN文档在AddOrUpdate方法上相当稀疏,所以我看了一下Reflector中的AddOrUpdate方法。这是基本算法(由于法律原因,我不会发布Reflector输出,并且很容易自己做)。

TValue value;
do
{
  if (!TryGetValue(...))
  {
    value = AddValueFactoryDelegate(key);
    if (!TryAddInternal(...))
    {
      continue;
    }
    return value;
  }
  value = UpdateValueFactoryDelegate(key);
} 
while (!TryUpdate(...))
return value;

很明显AddValueFactoryDelegateUpdateValueFactoryDelegate可以执行多次。这里不需要进一步解释。应该很明显这会如何破坏你的代码。我实际上有点震惊,代表们可以多次执行。该文件没有提到这一点。您会认为这将是一个非常重要的观点,因此呼叫者知道避免传递具有副作用的代表(就像您的情况一样)。

但即使代表们只保留执行一次仍然存在问题。通过将Enqueue方法替换为AddOrUpdate方法的内容,可以很容易地将问题序列可视化。 AddValueFactoryDelegate可以执行并将项插入_queue,但在将项添加到_set之前,线程可能会被上下文切换中断。然后,第二个帖子可以调用您的TryDequeue方法,并从_queue中提取该项,但无法将其从_set中删除,因为它尚未存在。

<强>更新

好吧,我认为不可能让它发挥作用。 ConcurrentQueue缺少一项关键操作。我相信您需要TryDequeue等效TryDequeueCas方法。如果存在这样的操作,那么我认为以下代码是正确的。我使用神秘的Interlocked.CompareExchange方法接受一个比较值,该值用作条件,以便当且仅当队列中的顶部项等于比较值时才原子地执行此操作。这个想法与bool方法中使用的完全相同。

注意代码如何使用ConcurrentDictionary中的TryUpdate值作为“虚拟”锁来同步队列和字典的协调。数据结构还包含CAS等效操作while,用于获取和释放此“虚拟”锁。并且因为锁是“虚拟的”并且实际上不阻止并发访问,所以TryDequeue方法中的ConcurrentQueue.TryDequeueCas循环是强制性的。这符合CAS操作的规范模式,因为它们通常在循环中执行,直到它们成功。

代码还使用CAS来获取锁定获取语义,以帮助防止由带外(异步)异常引起的问题。

注意:同样,代码使用神秘的class ConcurrentSetQueue<T> { ConcurrentQueue<T> _queue = new ConcurrentQueue<T>(); ConcurrentDictionary<T, bool> _set = new ConcurrentDictionary<T, bool>(); public ConcurrentSetQueue() { } public bool Enqueue(T item) { bool acquired = false; try { acquired = _set.TryAdd(item, true); if (acquired) { _queue.Enqueue(item); return true; } return false; } finally { if (acquired) _set.TryUpdate(item, false, true); } } public bool TryDequeue(out T item) { while (_queue.TryPeek(out item)) { bool acquired = false; try { acquired = _set.TryUpdate(item, true, false); if (acquired) { if (_queue.TryDequeueCas(out item, item)) { return true; } } } finally { if (acquired) _set.TryRemove(item, out acquired); } } item = default(T); return false; } } 方法。

Enqueue

更新2:

参考您的修改通知,与我的相比,它是多么相似。事实上,如果您从我的变体中删除了所有的绒毛,TryDequeue方法的完全相同的语句序列。

我更担心Enqueue这就是为什么我添加了“虚拟”锁定概念,这在我的实现中需要很多额外的东西。我特别担心访问数据结构的相反顺序(字典然后在TryDequeue方法中排队,但在{{1}}方法中排队然后字典)但是,我对你修改过的方法的考虑越多我越喜欢它。我现在认为它是因为反向访问顺序的是安全的!

答案 1 :(得分:1)

看看Eric Lppert的博客。你可能会发现你喜欢的东西...... http://blogs.msdn.com/b/ericlippert/archive/tags/immutability/