List<T>.AddRange传入ConcurrentDictionary抛出ArgumentException的原因及解决
实验场景与问题
我怀疑List<T>.AddRange传入并发集合时存在安全性问题,做了如下实验:
ConcurrentDictionary<int, int> dictionary = new(); for (int i = 1; i <= 50_000; i++) dictionary.TryAdd(i, default); List<KeyValuePair<int, int>> list = new(); Thread thread = new(() => { for (int i = -1; i >= -50_000; i--) dictionary.TryAdd(i, default); }); thread.Start(); list.AddRange(dictionary); // 抛出异常 thread.Join(); Console.WriteLine($"dictionary.Count: {dictionary.Count:#,0}, list.Count: {list.Count:#,0}");
初始化包含50,000个正键的ConcurrentDictionary后,在另一个线程中添加50,000个负键,同时调用list.AddRange(dictionary)。预期最终字典有100,000个键,列表元素数在50,000到100,000之间,但实际抛出ArgumentException:
Unhandled exception. System.ArgumentException: The index is equal to or greater than the length of the array, or the number of elements in the dictionary is greater than the available space from index to the end of the destination array. at System.Collections.Concurrent.ConcurrentDictionary`2.System.Collections.Generic.ICollection<System.Collections.Generic.KeyValuePair<TKey,TValue>>.CopyTo(KeyValuePair`2[] array, Int32 index) at System.Collections.Generic.List`1.InsertRange(Int32 index, IEnumerable`1 collection) at System.Collections.Generic.List`1.AddRange(IEnumerable`1 collection) at Program.Main()
即使将字典转为IEnumerable<T>类型传入,同样会抛出异常:
IEnumerable<KeyValuePair<int, int>> enumerable = dictionary; list.AddRange(enumerable); // 抛出异常
这种行为降低了对List<T>.AddRange API的使用信心,需要明确:
- 异常产生的原因是什么?
- 如何避免?
- 有没有办法确保
list.AddRange(dictionary)始终执行成功且不抛出异常?
异常原因
List<T>.AddRange针对实现ICollection<T>的集合会做性能优化:
- 先调用集合的
Count属性获取元素数量,以此创建对应大小的目标数组。 - 调用集合的
CopyTo方法,将元素批量复制到数组中。
但ConcurrentDictionary是并发集合:
Count返回的是即时快照值,在并发修改场景下,获取Count后到执行CopyTo的间隙,集合元素数量可能已经增加。ConcurrentDictionary的CopyTo实现会检查目标数组的剩余空间是否足够容纳当前所有元素,当实际元素数超过数组大小(因为并发添加了新元素),就会抛出ArgumentException。
简单来说:List.AddRange依赖集合状态的稳定性,但并发集合在操作期间状态可能变化,导致CopyTo的前置条件(数组空间足够)被破坏,从而抛出异常。
解决方案
1. 已知底层是ConcurrentDictionary的场景
使用ConcurrentDictionary.ToArray()方法先获取集合的原子快照,再传入AddRange:
// 先获取并发字典的固定快照数组,再添加到列表 list.AddRange(dictionary.ToArray());
ToArray()会原子性地捕获当前字典的所有元素,返回一个固定大小的数组,后续的并发修改不会影响这个数组,AddRange处理固定数组时能保证空间足够,不会抛出异常。
2. 仅知道是IEnumerable的场景
将IEnumerable<T>转为固定大小的集合(如数组)后再调用AddRange:
// 先将枚举转为数组,确保集合状态稳定 list.AddRange(enumerable.ToArray());
或者手动逐个添加元素(虽然性能略低,但同样安全):
foreach (var item in enumerable) { list.Add(item); }
ToArray()会完整枚举一次集合,即使是并发集合,枚举过程不会抛出异常(可能包含部分并发添加的元素,但不会出现空间不足的问题),得到的数组是稳定的,能安全传入AddRange。
3. 确保AddRange始终成功的方法
核心是先获取并发集合的稳定快照,再将快照传入AddRange。无论是ConcurrentDictionary.ToArray()还是对IEnumerable<T>调用ToArray()/ToList(),本质都是将可变的并发集合转为固定的非并发集合,消除AddRange执行期间集合状态变化的风险。
补充说明
List<T>.AddRange的设计目标是针对非并发场景,它假设传入的集合在操作期间不会被修改。并发集合的ICollection<T>实现本身不保证在并发修改时支持CopyTo到固定大小的数组,因此这并非AddRange的API缺陷,而是场景不匹配导致的问题——在并发修改的集合上使用依赖稳定状态的API,需要额外处理快照逻辑。
内容的提问来源于stack exchange,提问作者Theodor Zoulias

