Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
theodorzoulias opened this issue Jan 19, 2025 · 6 comments
Labels
area-System.Collections untriaged New issue has not been triaged by the area owner

Comments

@theodorzoulias
Copy link
Contributor

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.

@dotnet-policy-service dotnet-policy-service bot added the untriaged New issue has not been triaged by the area owner label Jan 19, 2025
Copy link
Contributor

Tagging subscribers to this area: @dotnet/area-system-collections
See info in area-owners.md if you want to be subscribed.

@eiriktsarpalis
Copy link
Member

This looks like a variation on dotnet/runtime#84163, so I don't think there is much we could do that wouldn't be a breaking change. FWIW mutable types and optimistic transactions don't mix in my experience, I would probably have switched the example to use an immutable queue as its value type.

@eiriktsarpalis eiriktsarpalis closed this as not planned Won't fix, can't repro, duplicate, stale Jan 20, 2025
@eiriktsarpalis eiriktsarpalis removed the untriaged New issue has not been triaged by the area owner label Jan 20, 2025
@theodorzoulias
Copy link
Contributor Author

FWIW mutable types and optimistic transactions don't mix in my experience, I would probably have switched the example to use an immutable queue as its value type.

Using an ImmutableQueue<T> or ConcurrentQueue<T> as TValue doesn't reproduce the issue. The issue emerges only if the TValue is mutable and non-thread-safe. As long as the developer synchronizes all accesses to the mutable TValue instances, I would expect that the overall usage pattern should be safe.

@stephentoub
Copy link
Member

As long as the developer synchronizes all accesses to the mutable TValue instances, I would expect that the overall usage pattern should be safe.

But in the example the developer is not doing so. The dev is calling AddOrUpdate, which needs to do a comparison, and is concurrently mutating the value that needs to be compared. It's no different than if you replaced the AddOrUpdate with a direct call to SequenceEqual or to the value's Equal, outside of the custom lock on the instance that's protecting the mutation but not the read.

So I'm not sure what this issue is trying to achieve. Are you asking for the docs to be more explicit about AddOrUpdate comparing the value?

@theodorzoulias
Copy link
Contributor Author

So I'm not sure what this issue is trying to achieve. Are you asking for the docs to be more explicit about AddOrUpdate comparing the value?

Mr. Toub the purpose of this issue is to raise awareness that AddOrUpdate's hidden dependency on the EqualityComparer<TValue>.Default has thread-safety implications, on top of the already known performance implications. That's something I didn't know a couple of days ago, and I assumed that neither Microsoft was aware of it, so I reported it. What should be done about this issue is honestly none of my business. Since changing the implementation of the ConcurrentDictionary<TKey,TValue> has been ruled out, I guess that enhancing the documentation with more detailed guidance is the next logical alternative, but I am not asking for it, neither I am willing to contribute to it. I think that I've done my part by reporting the issue.

@stephentoub
Copy link
Member

Thanks. Making the docs more explicit about AddOrUpdate needing to compare the values would be fine.

@eiriktsarpalis eiriktsarpalis transferred this issue from dotnet/runtime Jan 20, 2025
@dotnet-policy-service dotnet-policy-service bot added the untriaged New issue has not been triaged by the area owner label Jan 20, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-System.Collections untriaged New issue has not been triaged by the area owner
Projects
None yet
Development

No branches or pull requests

3 participants