基于 class 的当前实现,通过直接枚举将 ConcurrentDictionary 复制到普通字典是否安全?

Is it safe to copy a ConcurrentDictionary to a normal Dictionary, by enumerating it directly, based on the current implementation of the class?

TL;DR: ConcurrentDictionary 的单个枚举是否可能发出相同的密钥两次? ConcurrentDictionary class (.NET 5) 的 current implementation 是否允许这种可能性?


我有一个ConcurrentDictionary<string, decimal>被多个线程并发变异,我想定期将它复制到一个普通的Dictionary<string, decimal>,并传递给表现层更新UI.有两种复制方式,带快照语义和不带快照语义:

var concurrent = new ConcurrentDictionary<string, decimal>();

var copy1 = new Dictionary<string, decimal>(concurrent.ToArray()); // Snapshot

var copy2 = new Dictionary<string, decimal>(concurrent); // On-the-go

我很确定第一种方法是安全的,因为 ToArray 方法 returns 与 ConcurrentDictionary:

的观点一致

Returns a new array containing a snapshot of key and value pairs copied from the ConcurrentDictionary<TKey,TValue>.

但我更愿意使用第二种方法,因为它产生的争用较少。 我担心获得 ArgumentException: An item with the same key has already been added. 的可能性 documentation 似乎并不排除这种可能性:

The enumerator returned from the dictionary ... does not represent a moment-in-time snapshot of the dictionary. The contents exposed through the enumerator may contain modifications made to the dictionary after GetEnumerator was called.

这是让我担心的场景:

  1. 线程A开始枚举ConcurrentDictionary,枚举器发出键X。然后线程被OS.
  2. 暂时挂起
  3. 线程 B 移除密钥 X
  4. 线程 C 使用键 X 添加了一个新条目。
  5. 线程A继续枚举ConcurrentDictionary,枚举器观察到新添加的X条目,并发出它。
  6. Dictionary class 的构造函数试图将两次键 X 插入到新构造的 Dictionary 中,并抛出异常。

我试图重现这个场景,但没有成功。但这并不是 100% 令人放心,因为可能导致这种情况出现的条件可能很微妙。也许我添加的值没有“正确”的哈希码,或者没有生成“正确”的哈希码冲突数。我试图通过研究 class 的 source code 找到答案,但不幸的是它太复杂了我无法理解。

我的问题是:基于当前的实现 (.NET 5),通过直接枚举创建我的 ConcurrentDictionary 的快速副本是否安全,还是我应该防御性地编码并在每次复制它时拍摄快照?


澄清: 我同意任何人的说法,即使用 API 并考虑其未记录的实施细节是不明智的。但是,唉,这就是这个问题的全部内容。这是一个很有教育意义的问题,出于好奇。我不打算在生产代码中使用所获得的知识,我保证。

Is it possible in practice for a single enumeration of a ConcurrentDictionary, to emit the same key twice?

这取决于您如何定义“实践”。但根据我的定义,是的 在实践中 ConcurrentDictionary 绝对有可能发出相同的密钥两次。也就是说,您不能编写正确的代码,假设它不会。

The documentation clearly states:

The contents exposed through the enumerator may contain modifications made to the dictionary after GetEnumerator was called.

它没有提供关于行为的其他陈述,这意味着当调用 GetEnumerator() 时可能存在一个键,由例如返回。第一个枚举元素,之后被删除,然后以允许枚举器再次检索相同键的方式再次添加。

这是唯一我们可以在实践中的事情。

现在,也就是说,学术上(即不是在实践中)…

Does the current implementation of the ConcurrentDictionary class (.NET 5) allow this possibility?

根据 the implementation of GetEnumerator() 的检查,我 似乎 当前 实施 可能 避免多次返回同一个密钥的可能性。

根据代码中的注释,内容为:

// Provides a manually-implemented version of (approximately) this iterator:
//     Node?[] buckets = _tables._buckets;
//     for (int i = 0; i < buckets.Length; i++)
//         for (Node? current = Volatile.Read(ref buckets[i]); current != null; current = current._next)
//             yield return new KeyValuePair<TKey, TValue>(current._key, current._value);

然后查看 “手动实现的版本” 评论指的是......我们可以看到该实现只是遍历 buckets 数组,然后在每个桶内,遍历构成该桶的链表,就像评论中的示例代码所建议的那样。

但是看看 the code that adds a new element to a bucket,我们看到了这个:

// The key was not found in the bucket. Insert the key-value pair.
var resultNode = new Node(key, value, hashcode, bucket);
Volatile.Write(ref bucket, resultNode);
checked
{
    tables._countPerLock[lockNo]++;
}

当然,方法远不止于此,但这是症结所在。此代码将 bucket 列表的头部传递给新节点构造函数,后者又将新节点 插入列表 的头部。然后 bucket 变量,即 ref 变量,被新的节点引用覆盖。

即新节点成为列表的新头。

所以我们看到:

  • 枚举器在首次调用 MoveNext() 时从字典中捕获当前 _buckets 数组。
  • 这意味着即使字典必须重新分配其后备存储以增加桶的数量,枚举器也会继续遍历之前的数组。
  • 此外,如果重新分配,则不会重复使用旧链表。 The code that reallocates the storage 为整个集合创建所有新链表:
// Copy all data into a new table, creating new nodes for all elements
foreach (Node? bucket in tables._buckets)
{
    Node? current = bucket;
    while (current != null)
    {
        Node? next = current._next;
        ref Node? newBucket = ref newTables.GetBucketAndLock(current._hashcode, out uint newLockNo);

        newBucket = new Node(current._key, current._value, current._hashcode, newBucket);

        checked
        {
            newCountPerLock[newLockNo]++;
        }

        current = next;
    }
}
  • 这意味着最坏的情况是在没有重新分配后备存储的情况下删除并重新添加元素(因为这是使用当前正在迭代的相同链表的唯一方法),因此关键风在同一个链表中。
  • 但我们知道新节点总是被添加到列表的头部。并且枚举器没有任何类型的回溯,这将允许它看到添加在列表头部的新节点。它所能做的就是继续执行已经存在的列表的其余部分。

相信这意味着您不能两次获得相同的密钥。

也就是说,我要指出:ConcurrentDictionary 代码很复杂。我很会看代码,觉得上面的分析是正确的。但我无法保证。哎呀,甚至在通读代码时,我 两次 改变了我对什么是可能的和什么不是的看法,因为我没有考虑到特定的可能性。我可能仍然忽略了一些东西,一些极端情况,例如其中链表枚举以某种方式 returns 到头部,或者 _buckets 数组以某种方式调整大小而不是创建原始数组的全新副本(你不能在严格的 C# 代码中这样做,但 CLR 可能会以性能的名义使用各种肮脏的把戏。

更重要的是,none 这一点很重要。底层实现可能会因任何原因在任何一天发生变化(例如,他们可能会在代码中发现一个错误,而该错误根本无法使用代码的“迭代期间无重复键”版本修复)。并且鉴于您的原始问题是在将字典内容作为快照复制到另一个数据结构的上下文中提出的,并且 ConcurrentDictionary class 实际上确实有一个 ToArray() 方法来提供功能,没有理由编写任何可能会遇到其中一种可能的极端情况的代码。