问题描述
TL;DR: ConcurrentDictionary
的单次枚举是否可能发出相同的密钥两次? ConcurrentDictionary
类 (.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
方法返回 ConcurrentDictionary
的一致视图:
返回一个包含从 ConcurrentDictionary<TKey,TValue>
复制的键值对快照的新数组。
但我更愿意使用第二种方法,因为它产生的争用较少。
虽然我担心获得 ArgumentException: An item with the same key has already been added.
的可能性,但 documentation 似乎并不排除这种可能性:
从字典返回的枚举数......并不代表字典的即时快照。通过枚举器公开的内容可能包含在调用 GetEnumerator
后对字典所做的修改。
这是让我担心的场景:
- 线程 A 开始枚举
ConcurrentDictionary
,键X
由枚举器发出。然后线程被操作系统暂时挂起。 - 线程 B 删除了键
X
。 - 线程 C 添加了一个键为
X
的新条目。 - 线程 A 继续枚举
ConcurrentDictionary
,枚举器观察新添加的X
条目,并发出它。 -
Dictionary
类的构造函数尝试将键X
两次插入到新构造的Dictionary
中,并引发异常。
我试图重现这个场景,但没有成功。但这并不是 100% 令人放心,因为可能导致这种情况出现的条件可能很微妙。也许我添加的值没有“正确”的哈希码,或者没有生成“正确”数量的哈希码冲突。我试图通过研究课程的source code来找到答案,但不幸的是它太复杂了,我无法理解。
我的问题是:基于当前的实现 (.NET 5),通过直接枚举创建我的 ConcurrentDictionary
的快速副本是否安全,或者我应该进行防御性编码每次复制时都拍一张快照?
澄清:我同意任何人所说的使用 API 时考虑其未记录的实现细节是不明智的。但是,唉,这就是这个问题的全部内容。这是一个相当有教育意义的,出于好奇的问题。我不打算在生产代码中使用所获得的知识,我保证。 ?
解决方法
在实践中,ConcurrentDictionary 的单个枚举是否可能发出两次相同的键?
这取决于您如何定义“实践中”。但是根据我的定义,是的,在实践中,ConcurrentDictionary
绝对有可能发出相同的键两次。也就是说,您无法编写正确的代码来假设它不会。
The documentation clearly states:
通过枚举器暴露的内容可能包含在调用 GetEnumerator 后对字典所做的修改。
它没有提供关于行为的其他声明,这意味着调用 GetEnumerator()
时可能存在一个键,例如返回第一个被枚举的元素,之后被删除,然后在稍后以允许枚举器再次检索相同键的方式再次添加。
这是我们唯一可以依靠的实践。
现在,也就是说,学术上(即不是在实践中)......
ConcurrentDictionary 类 (.NET 5) 的当前实现是否允许这种可能性?
在检查 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
变量)被新节点引用覆盖。
即新节点成为链表的新头。
所以我们看到:
- 第一次调用
_buckets
时,枚举器从字典中捕获当前的MoveNext()
数组。 - 这意味着即使字典必须重新分配其后备存储以增加存储区的数量,枚举器也将继续遍历前一个数组。
- 此外,如果重新分配,旧的链表不会被重用。 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
代码很复杂。我很会看代码,觉得上面的分析是对的。但我不能保证这一点。哎呀,即使在通读代码时,我也对什么是可能的,什么不是两次的看法发生了变化,因为我没有考虑特定的可能性。我可能仍然忽略了一些东西,一些极端情况,例如其中链表枚举以某种方式返回到头部,或者 _buckets
数组以某种方式在适当位置调整大小而不是创建原始数组的全新副本(您不能在严格的 C# 代码中这样做,但是 CLR有各种可能以性能为名的肮脏技巧)。
更重要的是,这些都不重要。底层实现可能因任何原因在任何一天发生变化(例如,他们可能在代码中发现了一个无法使用“迭代期间没有重复键”版本的代码来修复的错误)。鉴于您的原始问题是在将字典内容作为快照复制到另一个数据结构的上下文中提出的,而 ConcurrentDictionary
类实际上确实有一个 ToArray()
方法来提供该功能,因此没有编写任何可能会遇到这些可能的极端情况之一的代码的理由。