在 Dictionary 和 ConcurrentDictionary 之间修改集合时的不同行为

Different behaviour when collection modified between Dictionary and ConcurrentDictionary

使用下面列表中的普通词典代码,我得到异常

Collection was modified; enumeration operation may not execute.

Dictionary<int, int> dict2 = new Dictionary<int, int>();
dict2.Add(1, 10);
dict2.Add(2, 20);
dict2.Add(3, 30);
dict2.Add(4, 40);

foreach (var d in dict2)
{
    if (dict2.ContainsKey(2))
        dict2.Remove(2);

    if (dict2.ContainsKey(3))
        dict2.Remove(3);
}

但是对于 ConcurrentDictionary,这工作正常。

ConcurrentDictionary<int, int> dict1 = new ConcurrentDictionary<int, int>();
dict1.AddOrUpdate(1, 10, (k,v)=> 10);
dict1.AddOrUpdate(2, 20, (k, v) => 20);
dict1.AddOrUpdate(3, 30, (k,v)=> 30);
dict1.AddOrUpdate(4, 40, (k,v)=> 40);

foreach (var d in dict1)
{
    int x;
    if (dict1.ContainsKey(2))
        dict1.TryRemove(2, out x);

    if (dict1.ContainsKey(3))
        dict1.TryRemove(3, out x);
}

为什么行为会有所不同?

原因是 Dictionary 和 ConcurrentDictionary 有不同的用途。 ConcurrentDictionary - 应该处理并发问题(从不同线程编辑),而 Dictionary 会给你更好的性能。

不同行为的原因是:GetEnumerator() 方法的不同实现。

现在我将解释 Dictionary 异常的原因以及 ConcurrentDictionary 没有异常的原因。

foreach 语句是语法糖,类似于:

    var f = dict.GetEnumerator();

        while (f.MoveNext())
        {
            var x = f.Current;

            // your logic
        }

词典 return 中的 "GetEnumerator()" 结构的新实例名为:"Enumerator"

这个结构实现了:IEnumerator >KeyValuePair>TKey,TValue>>,IDictionaryEnumerator 和他的 C'tor 看起来像:

        internal Enumerator(Dictionary<TKey,TValue> dictionary, int getEnumeratorRetType) {
            this.dictionary = dictionary;
            version = dictionary.version;
            index = 0;
            this.getEnumeratorRetType = getEnumeratorRetType;
            current = new KeyValuePair<TKey, TValue>();
        }

"Enumerator" 中 MoveNext() 的实现首先验证源字典未被修改:

      bool moveNext(){
            if (version != dictionary.version) {
                throw new InvalidOperationException....
            }
            //the itarate over...
      }

ConcurrentDictionary 中的 "GetEnumerator()" 实现方式不同:

   IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator(){
         Node[] buckets = m_tables.m_buckets;

         for (int i = 0; i < buckets.Length; i++)
         {

             Node current = Volatile.Read<Node>(ref buckets[i]);

             while (current != null)
             {
                 yield return new KeyValuePair<TKey, TValue>(current.m_key,  current.m_value);
                 current = current.m_next;
             }
         }
    }

在这个实现中有一个叫做 "lazy evaluation" 的技术,return 语句将 return 值。 当消费者调用 MoveNext() 时,您将 return 到 "current = current.m_next;" 所以,在 GetEnumerator().

中没有 "not change" 验证

如果你想避免 "Dictionary editing" 出现异常,那么: 1.迭代到要删除的元素 2.删除元素 3. 在调用 MoveNext() 之前中断

在你的例子中:

        foreach (var d in dict2)
        {
            if (dict2.ContainsKey(1))
                dict2.Remove(1);

            if (dict2.ContainsKey(3))
                dict2.Remove(3);

            break; // will prevent from exception
        }

有关 ConcurrentDictionary 的 GetEnumerator() 的更多信息: https://msdn.microsoft.com/en-us/library/dd287131(v=vs.110).aspx

ConcurrentDictionary 的目的是允许多个线程以最少的锁定使用它。如果一个线程希望从一个典型的并发数据结构中接收一个枚举,该枚举表示在某个时刻保存的数据的精确组合,则有必要使用锁来确保在快照的同时不会发生更新结构已构建。即使在使用 ConcurrentDictionary 时,想要构建这种快照的代码也可以使用这种方法。

然而,在许多情况下,代码将对满足以下所有条件的 任何 枚举感到满意:

  • 枚举将包括枚举前存在的所有数据项,在整个枚举过程中继续存在,不加修改。

  • 枚举将不包括枚举期间任何时候集合不包含的任何数据项。

  • 如果在枚举开始时集合不包含某项,但在枚举期间添加and/or修改了N次该项,则枚举应报告该项不超过N次。

  • 如果在枚举开始时集合中包含一个item,并且在枚举过程中添加and/or修改了N次,则枚举将报告该item不超过N+1次。

满足上述条件的枚举方法的成本可能比需要return一个"snapshot"的方法便宜;由于此类枚举通常很有用,因此 ConcurrentDictionary 将其 GetEnumerator 方法定义为 return 更便宜的方法。这种行为不会阻止代码使用外部锁定,但如果唯一可用的枚举器总是拍摄快照,那么当不需要精确的快照时,代码将无法使用更高性能的枚举。

PS--我碰巧认为 ConcurrentDictionary 包含一些明确请求其内容的可枚举快照的方法会有所帮助,即使拍摄这样的快照会相对较慢并且会阻止部分或所有并发访问。即使大型集合的快照太慢而不能经常使用,拥有集合的真实快照在许多调试场景中也是有用的。