Skip to content

Thread-safety implications of using EqualityComparer<TValue>.Default in ConcurrentDictionary #10852

Open
@theodorzoulias

Description

@theodorzoulias

Hi. Some time ago I posted an issue about the performance implications of using the default TValue comparer internally in the ConcurrentDictionary<TKey,TValue> collection. Recently I discovered that there is a thread-safety implication as well, so I would like to report it. The issue emerges under these conditions:

  1. The TValue is a mutable type, and implements the IEquatable<T> interface or overrides the object.Equals method.
  2. The Equals implementation depends on mutable fields of the type.
  3. A TValue instance is mutated on one thread, while a concurrent AddOrUpdate operation runs on another thread.

In this case the Equals might see the TValue instance in a transitional/invalid state, and throw an exception (or other undefined behavior).

Below is a demonstration of this issue. The TValue is a Genome class that derives from the Queue<char>, and compares itself with other Genome instances with the SequenceEqual method. Two threads are using the AddOrUpdate to replace Genome instances in the dictionary, and then they take an exclusive lock on the returned Genome instance and mutate it. The result is an InvalidOperationException:

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

public class Program
{
    class Genome : Queue<char>, IEquatable<Genome>
    {
        public bool Equals(Genome other) => this.SequenceEqual(other);
    }

    public static void Main()
    {
        ConcurrentDictionary<int, Genome> dictionary = new();
        Thread[] threads = Enumerable.Range(1, 2).Select(i => new Thread(() =>
        {
            while (true)
            {
                Genome genome = dictionary.AddOrUpdate(1, _ => new(), (_, _) => new());
                lock (genome) genome.Enqueue('A');
            }
        })).ToArray();
        Array.ForEach(threads, t => t.Start());
        Array.ForEach(threads, t => t.Join());
    }
}

Output:

Unhandled exception. System.InvalidOperationException: Collection was modified; enumeration operation may not execute.
   at System.Collections.Generic.Queue`1.Enumerator.MoveNext()
   at System.Linq.Enumerable.SequenceEqual[TSource](IEnumerable`1 first, IEnumerable`1 second, IEqualityComparer`1 comparer)
   at System.Linq.Enumerable.SequenceEqual[TSource](IEnumerable`1 first, IEnumerable`1 second)
   at Program.Genome.Equals(Genome other)
   at System.Collections.Concurrent.ConcurrentDictionary`2.TryUpdateInternal(TKey key, Nullable`1 nullableHashcode, TValue newValue, TValue comparisonValue)
   at System.Collections.Concurrent.ConcurrentDictionary`2.AddOrUpdate(TKey key, Func`2 addValueFactory, Func`3 updateValueFactory)
   at Program.<>c__DisplayClass1_0.<Main>b__3()
   at System.Threading.Thread.StartCallback()

Online demo.

This looks to me like a valid/intended use of the ConcurrentDictionary<TKey,TValue> collection, but I might be wrong.

I experimented with the ImmutableDictionary<TKey,TValue> as well, expecting to be unaffected from this issue, but to my surprise it is affected as well (demo). This collection also makes calls to the EqualityComparer<TValue>.Default.Equals when it is updated atomically with the ImmutableInterlocked.AddOrUpdate API. At least the immutable dictionary is equipped with the WithComparers method, that allows to customize the valueComparer that is used internally by the collection.

I should clarify that the above example is contrived. I haven't been personally affected by this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions