Bizarre concurrent behavior when I cast `ConcurrentDictionary` to an `IDictionary`

99 Views Asked by At

I have the following test:

using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;               

public class Program
{
    public static void Main()
    {
        IDictionary<string, string> dictionary = new ConcurrentDictionary<string, string>(); // FAILS sometimes
        // ConcurrentDictionary<string, string> dictionary = new ConcurrentDictionary<string, string>(); // WORKS always

        Parallel.For(0, 10, i => dictionary.TryAdd("x1", "y1"));
    }
}

If I run it in .NET Fiddle, I sometimes get the following exception:

Unhandled exception. System.AggregateException: One or more errors occurred. (The key already existed in the dictionary)
System.ArgumentException: The key already existed in the dictionary.

at System.Collections.Concurrent.ConcurrentDictionary`2.System.Collections.Generic.IDictionary<TKey,TValue>.Add(TKey key, TValue value)
at System.Collections.Generic.CollectionExtensions.TryAdd[TKey,TValue](IDictionary`2 dictionary, TKey key, TValue value)
at Program.<>c__DisplayClass0_0.b__0(Int32 i)
at System.Threading.Tasks.Parallel.<>c__DisplayClass19_0`2.b__1(RangeWorker& currentWorker, Int64 timeout, Boolean& replicationDelegateYieldedBeforeCompletion)
--- End of stack trace from previous location ---
at System.Threading.Tasks.Parallel.<>c__DisplayClass19_0`2.b__1(RangeWorker& currentWorker, Int64 timeout, Boolean& replicationDelegateYieldedBeforeCompletion)
at System.Threading.Tasks.TaskReplicator.Replica.Execute()
--- End of inner exception stack trace ---

However, if I don't cast ConcurrentDictionary, it seems to always work.

What's going on?

2

There are 2 best solutions below

0
On BEST ANSWER

This is actually covered in the ConcurrentDictionary<TKey,TValue> docs in the "Thread Safety" section which is hidden that far away so almost everybody misses it (emphasis mine):

Thread Safety

All public and protected members of ConcurrentDictionary<TKey,TValue> are thread-safe and may be used concurrently from multiple threads. However, members accessed through one of the interfaces the ConcurrentDictionary<TKey,TValue> implements, including extension methods, are not guaranteed to be thread safe and may need to be synchronized by the caller.

And the IDictionary<string, string> dictionary = .. falls under the extensions methods category, specifically CollectionExtensions.TryAdd<TKey,TValue>(IDictionary<TKey,TValue> dictionary, ...)

So in short - if you need to use the dictionary in thread-safe manner do not use it via interfaces/extension methods and use the methods listed in the Remarks section:

All these operations are atomic and are thread-safe with regards to all other operations on the ConcurrentDictionary<TKey,TValue> class...

0
On

The ConcurrentDictionary.TryAdd instance method operates in an atomic manner. If this method is called twice simultaneously with the same key, then one of the calls is guaranteed to return false (instead of throwing an exception).

The generic IDictionary interface has no TryAdd instance method. When you cast to IDictionary and call TryAdd, you're actually invoking the CollectionExtensions.TryAdd extension method. This method is implemented as follows:

if (!dictionary.ContainsKey(key))
{
    dictionary.Add(key, value);
    return true;
}
 
return false;

As you can see, this implementation is not atomic. Two simultaneous calls to TryAdd can both pass the ContainsKey check and then both call Add, which results in an exception.

Therefore, you can't cast to IDictionary and preserve the atomic behavior of the ConcurrentDictionary.TryAdd instance method.