Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

List<T>.AddRange throws ArgumentException when passing a ConcurrentDictionary as argument

Today I had a suspicion that the List<T>.AddRange method might not be safe to use with a concurrent collection as argument, so I made an experiment to find out:

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); // Throws

thread.Join();
Console.WriteLine($"dictionary.Count: {dictionary.Count:#,0}, list.Count: {list.Count:#,0}");

Online demo.

The ConcurrentDictionary is initialized with 50,000 positive keys. Then 50,000 additional negative keys are added on a different thread, concurrently with adding the dictionary in the list with the AddRange method. I was expecting that eventually the dictionary would have 100,000 keys, and the list somewhere between 50,000 and 100,000 items. In reality I got an 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()

My question is: Why is this happening, and how can I prevent it from happening? Is there any way to ensure that the list.AddRange(dictionary); line will be always successful, with no exceptions thrown?

Imagine that the dictionary might have been given to me as an IEnumerable<T>, and I have no idea about its underlying type. The same exception is thrown in this case as well:

IEnumerable<KeyValuePair<int, int>> enumerable = dictionary;
list.AddRange(enumerable); // Throws

This behavior reduces my confidence about using the List<T>.AddRange API in general.

Context: A similar symptom is mentioned in AddRange throwing ArgumentException">this question, but a minimal and reproducible example is not provided, so I am not sure that the scenario is the same. Another related question is while adding items">this, about calling the LINQ ToList on a ConcurrentDictionary<TKey, TValue>. Nevertheless the documentation warns about using extension methods on concurrent collections, but I am not seeing any warning against using a concurrent collection with the List<T>.AddRange method.

like image 607
Theodor Zoulias Avatar asked Oct 27 '25 20:10

Theodor Zoulias


2 Answers

What's happening is fairly straightforward.

List<T>.AddRange has a check to see whether the thing it was passed is an ICollection<T>. If so, it can optimize by using ICollection<T>.Count to allocate enough space for the new range in one go (instead of potentially resizing the list multiple times), and ICollection<T>.CopyTo to copy the collection's elements in one go, instead of adding them one-by-one.

The code is here:

if (collection is ICollection<T> c)
{
    int count = c.Count;
    if (count > 0)
    {
        if (_items.Length - _size < count)
        {
            Grow(checked(_size + count));
        }

        c.CopyTo(_items, _size);
        _size += count;
        _version++;
    }
}

ConcurrentDictionare<TKey, TValue> implements ICollection<KeyValuePair<TKey, TValue>>, and its implementations of Count and CopyTo are safe in themselves, but there's no inherent synchronization between them.

So List<T>.AddRange asks the dictionary for its size, allocates that number of new elements, then asks the dictionary to copy itself into that newly-allocated space. However, the dictionary has grown by that point, and throws an exception here:

int count = GetCountNoLocks();
if (array.Length - count < index)
{
    throw new ArgumentException(SR.ConcurrentDictionary_ArrayNotLargeEnough);
}

As for who's to "blame" here, I'm not sure. The optimization which List<T> is doing is sensible most of the time, and as a non-thread-safe collection it's not trying to be thread-safe. As @shingo notes, ConcurrentDictionary doesn't guarantee thread-safety when accessed through one of its interfaces, although it does its best. ICollection<T>.CopyTo is documented as throwing if the space it's being asked to copy into isn't large enough.

As for workarounds, the simplest and most obviously correct is to create an intermediate collection: list.AddRange(dict.ToArray()). However, this of course comes with the cost of an intermediate allocation, which may be large.

You could also wrap loop over the dictionary and use Add with each element (ConcurrentDictionary's GetEnumerator() is thread-safe), and this is effectively what you're expecting AddRange to do anyway.

I think in general you just need to be careful when mixing thread-safe and non-thread-safe types in this way. Make sure that you understand exactly what's going on, and exactly what thread-safe guarantees the types involved do and don't make.

like image 78
canton7 Avatar answered Oct 29 '25 09:10

canton7


As an addendum to Canton7's answer, you can "hide" the type from the optimisation that causes the problem by using a method like this:

public static IEnumerable<T> Enumerate<T>(IEnumerable<T> sequence)
{
    foreach (var item in sequence)
    {
        yield return item;
    }
}

Then you can call: list.AddRange(Enumerate(dictionary)); and it won't throw an exception. This will avoid having to make a copy of the collection.

like image 38
Matthew Watson Avatar answered Oct 29 '25 10:10

Matthew Watson



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!