根据类的当前实现,通过直接枚举 ConcurrentDictionary 将 ConcurrentDictionary 复制到普通 Dictionary 是否安全?

The*_*ias 5 c# multithreading dictionary concurrentdictionary

TL;DR: a 的单个枚举是否可以ConcurrentDictionary两次发出相同的键?该类的当前实现(.NET 5)是否ConcurrentDictionary允许这种可能性?


我有一个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
Run Code Online (Sandbox Code Playgroud)

我非常确定第一种方法是安全的,因为该ToArray方法返回以下内容的一致视图ConcurrentDictionary

返回一个新数组,其中包含从 复制的键和值对的快照ConcurrentDictionary<TKey,TValue>

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

从字典返回的枚举器...并不代表字典的即时快照。通过枚举器暴露的内容可能包含GetEnumerator调用后对字典所做的修改。

这是让我担心的情况:

  1. 线程 A 开始枚举ConcurrentDictionary,并且键X由枚举器发出。然后该线程被操作系统暂时挂起。
  2. 线程 B 移除钥匙X
  3. 线程 C 添加一个带有 key 的新条目X
  4. 线程 A 继续枚举ConcurrentDictionary,枚举器观察新添加的X条目,并将其发出。
  5. 该类的构造函数Dictionary尝试将两次键插入X到新构造的 中Dictionary,并引发异常。

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

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


澄清:我同意任何人的观点,即使用 API 时考虑其未记录的实现细节是不明智的。但可惜,这就是这个问题的全部内容。这是一个颇具教育意义、出于好奇的问题。我保证,我不打算在生产代码中使用所获得的知识。

Pet*_*iho 5

\n

在实践中,ConcurrentDictionary 的单个枚举是否可以两次发出相同的键?

\n
\n

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

\n

该文档明确指出

\n
\n

通过枚举器公开的内容可能包含调用 GetEnumerator 后对字典所做的修改。

\n
\n

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

\n

这是我们在实践中唯一可以依靠的。

\n

现在,也就是说,从学术上讲(即不在实践中)\xe2\x80\xa6

\n
\n

ConcurrentDictionary 类 (.NET 5) 的当前实现是否允许这种可能性?

\n
\n

在检查 的实现时GetEnumerator(),在我看来,当前的实现可以避免多次返回相同密钥的可能性。

\n

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

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

再看注释中提到的“手动实现的版本” \xe2\x80\xa6,我们可以看到实现无非就是遍历数组buckets,然后在每个桶内,遍历构成该桶的链表,正如注释中的示例代码所示。

\n

但是查看向存储桶添加新元素的代码,我们看到:

\n
// The key was not found in the bucket. Insert the key-value pair.\nvar resultNode = new Node(key, value, hashcode, bucket);\nVolatile.Write(ref bucket, resultNode);\nchecked\n{\n    tables._countPerLock[lockNo]++;\n}\n
Run Code Online (Sandbox Code Playgroud)\n

当然,该方法的意义远不止于此,但这就是关键。此代码将列表的头部传递bucket给新节点构造函数,该构造函数又将新节点插入列表的头部。然后这个bucket变量,也就是一个ref变量,就被新的节点引用覆盖了。

\n

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

\n

所以我们看到:

\n
    \n
  • 首次调用_buckets时,枚举器从字典中捕获当前数组。MoveNext()
  • \n
  • 这意味着即使字典必须重新分配其后备存储以增加存储桶的数量,枚举器也将继续迭代以前的数组。
  • \n
  • 此外,如果重新分配,旧的链表将不会被重用。重新分配存储的代码为整个集合创建所有新的链表:
  • \n
\n
// Copy all data into a new table, creating new nodes for all elements\nforeach (Node? bucket in tables._buckets)\n{\n    Node? current = bucket;\n    while (current != null)\n    {\n        Node? next = current._next;\n        ref Node? newBucket = ref newTables.GetBucketAndLock(current._hashcode, out uint newLockNo);\n\n        newBucket = new Node(current._key, current._value, current._hashcode, newBucket);\n\n        checked\n        {\n            newCountPerLock[newLockNo]++;\n        }\n\n        current = next;\n    }\n}\n
Run Code Online (Sandbox Code Playgroud)\n
    \n
  • 这意味着最坏的情况是在没有重新分配后备存储的情况下删除并重新添加元素(因为这是使用当前正在迭代的相同链表的唯一方法),因此键最终会出现在同一个链接中列表。
  • \n
  • 但我们知道新节点总是会添加到列表的头部。并且枚举器没有任何类型的回溯,这将允许它看到添加到列表头部的新节点。它所能做的就是继续处理已经存在的列表的其余部分。
  • \n
\n

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

\n

也就是说,我要指出:ConcurrentDictionary代码很复杂。我很擅长阅读代码,并且认为上面的分析是正确的。但我不能保证这一点。哎呀,即使在阅读代码时,我也两次改变了对什么是可能的、什么是不可能的看法,因为我没有考虑到特定的可能性。我可能仍然忽略了一些东西,一些极端的情况,例如链接列表枚举以某种方式返回到头部,或者数组_buckets以某种方式调整大小,而不是创建原始数组的全新副本(你不能在严格的 C# 代码,但 CLR 有各种以性能为名的肮脏伎俩)。

\n

更重要的是,这些都不重要。底层实现可能会因任何原因而随时发生变化(例如,他们可能会在代码中发现一个错误,而该错误根本无法使用“迭代期间无重复键”版本的代码来修复)。考虑到您最初的问题是在将字典内容作为快照复制到另一个数据结构的上下文中提出的,并且该类ConcurrentDictionary实际上有一个ToArray()方法来提供确切的功能,因此没有理由编写任何可能的代码偶然发现这些可能的极端情况之一。

\n