Skip to content

ConcurrentDictionary.GetOrCreate(key) throws ArgumentException when two threads race on a missing key #134

Closed

Description

@matt-edmondson

What's wrong

PR #131 (commit 01b92d0) made the ConcurrentDictionary<TKey,TVal>.GetOrCreate(key, defaultValue) overload atomic by delegating to GetOrAdd (Extensions/DictionaryExtensions.cs:65-99). The one-argument form has no ConcurrentDictionary overload, though. concurrentDict.GetOrCreate(key) binds to the IDictionary overload at Extensions/DictionaryExtensions.cs:20:

public static TVal GetOrCreate<TKey, TVal>(this IDictionary<TKey, TVal> dictionary, TKey key) ... => GetOrCreate(dictionary, key, new TVal());

Inside that method dictionary has the static type IDictionary, so the call goes to the non-atomic IDictionary 3-argument overload (:31-58):

if (dictionary.TryGetValue(key, out TVal? val)) { return val; }
dictionary.Add(key, defaultValue);   // IDictionary.Add on ConcurrentDictionary throws if the key now exists

Failure scenario

  1. Thread A calls d.GetOrCreate(1) on an empty ConcurrentDictionary<int, List<int>>, and TryGetValue misses.
  2. Thread B does the same, and its TryGetValue also misses.
  3. Thread A runs Add(1, listA) and succeeds.
  4. Thread B runs Add(1, listB), which throws ArgumentException ("An item with the same key has already been added").
  • Expected: both callers get the stored instance, as the (key, defaultValue) overload now guarantees.
  • Actual: the losing caller crashes.

How verified

Built the library for net10.0 and ran a console program. In each of 2000 iterations, 8 tasks released through a Barrier all call d.GetOrCreate(1) on a fresh ConcurrentDictionary<int, List<int>>. Output:

GetOrCreate(key) on ConcurrentDictionary: 1466 ArgumentExceptions, 0 iterations with differing instances

The existing test GetOrCreateConcurrentDictionaryShouldReturnSameInstanceToParallelCallers only covers the two-argument form, so it does not catch this.

Suggested fix / acceptance criteria

  • Add a ConcurrentDictionary overload for the one-argument form, for example public static TVal GetOrCreate<TKey,TVal>(this ConcurrentDictionary<TKey,TVal> dictionary, TKey key) where TKey : notnull where TVal : new() => dictionary.GetOrAdd(key, _ => new TVal());, with the same null checks. Alternatively, have the IDictionary path detect ConcurrentDictionary and call GetOrAdd.
  • Add a test that runs Parallel.For over dictionary.GetOrCreate("key1").Add(i) on a ConcurrentDictionary<string, List<int>>. It should not throw and should end with 1000 items in one list.

Activity

  1. matt-edmondson commented on Sep 27, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    • Category: Bug (concurrency, incomplete fix)
    • Priority: High. concurrentDict.GetOrCreate(key) throws ArgumentException under contention (1466 of 2000 iterations in the repro). Make the ConcurrentDictionary GetOrCreate overload atomic #131 made the two-argument form atomic, but the one-argument form still binds to the non-atomic IDictionary path, so callers relying on that guarantee crash.
    • Area / suggested owner: Extensions/DictionaryExtensions.cs
    • Duplicates / in progress: Follow-up to Make the ConcurrentDictionary GetOrCreate overload atomic #131 (commit 01b92d0). No open PR.
    • Notes: Add a ConcurrentDictionary one-argument overload that uses GetOrAdd(key, _ => new TVal()), and a Parallel.For test on the one-argument form. Also audit the other IDictionary extension overloads for the same binding trap.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions