Concurrent Collections

ConcurrentDictionary and friends are safe to use from many threads, but a plain List behind one lock is often the simpler correct answer.

Syntaxint value = dictionary.GetOrAdd(key, k => Compute(k));

The types in System.Collections.Concurrent are collections built to be used from several threads at once: ConcurrentDictionary<TKey, TValue>, ConcurrentQueue<T>, ConcurrentStack<T>, ConcurrentBag<T> and BlockingCollection<T>. A plain Dictionary or List written from two threads can lose entries or corrupt its own internal arrays, and nothing warns you at compile time.

What thread-safe actually promises

One call is safe. A sequence of calls is not. TryGetValue followed by TryAdd is still a check followed by an action, so another thread can slip between them. That is why these types offer combined methods: GetOrAdd for read-or-create, and AddOrUpdate for create-or-change.

GetOrAdd(key, valueFactory) returns the stored value when the key is present and otherwise calls your factory and stores that. The factory runs outside the dictionary's internal locks, so when two threads miss the same key it can run twice. Only one result is kept, and both callers receive that one. Your factory must therefore be safe to run twice with one answer thrown away.

Often you want a lock instead

There is no concurrent list, and no way to ask a concurrent dictionary to treat two operations as one. If your rule spans more than one call, or spans two collections, you need a critical section anyway. A plain List<T> behind a single Lock field is shorter, easier to reason about and usually fast enough. Reach for a concurrent type when many threads really do hammer the same collection.

Example

Example · csharp
using System.Collections.Concurrent;

ConcurrentDictionary<string, int> counts = new();

Task Tally(string[] words) => Task.Run(() =>
{
    foreach (string word in words)
    {
        // AddOrUpdate stores 1 when the key is absent, and otherwise
        // replaces the stored value with old + 1. No count is lost.
        counts.AddOrUpdate(word, 1, (_, old) => old + 1);
    }
});

await Task.WhenAll(
    Tally(["red", "blue", "red"]),
    Tally(["blue", "green", "red"]));

// Enumeration order of a dictionary is not defined, so sort the keys first.
foreach (string key in counts.Keys.Order())
{
    Console.WriteLine($"{key}: {counts[key]}");
}

// Output:
// blue: 2
// green: 1
// red: 3

When to use it

  • Caching a parsed configuration object per tenant across concurrent requests.
  • Collecting results from many workers into a queue that one task drains.
  • Counting events by name from several background tasks at once.
  • Replacing a lock around a dictionary that profiling shows is heavily contended.

More examples

GetOrAdd and the loop it replaces

Read the long version once and the short one stops looking like magic. Both can compute the value twice when two threads miss the same key at the same time, and both guarantee every caller ends up with the same stored value. The long form is what you have to write when the value needs an await to produce, because GetOrAdd takes a plain function.

Example · csharp
using System.Collections.Concurrent;

public sealed class SizeCache
{
    private readonly ConcurrentDictionary<string, int> _sizes = new();

    // Shorthand.
    public int GetShort(string key)
    {
        return _sizes.GetOrAdd(key, k => k.Length);
    }

    // Longhand. GetOrAdd is this loop, written once inside the library.
    public int GetLong(string key)
    {
        while (true)
        {
            if (_sizes.TryGetValue(key, out int existing))
            {
                return existing;
            }

            int computed = key.Length;
            if (_sizes.TryAdd(key, computed))
            {
                return computed;
            }

            // Another thread added the key first. Loop and return theirs.
        }
    }
}

A queue drained with TryDequeue

Use this shape whenever many producers add work and one or more consumers take it. The Try prefix is the whole design: every operation that could race is written as a single call that reports what happened, rather than a question you ask and then act on. For a consumer that should wait for new items rather than spin on an empty queue, a channel is the better tool.

Example · csharp
using System.Collections.Concurrent;

ConcurrentQueue<string> work = new();
work.Enqueue("a");
work.Enqueue("b");

// TryDequeue returns false on an empty queue instead of throwing. There is no
// safe Count-then-read, because another thread could take the item between
// the two calls.
while (work.TryDequeue(out string? item))
{
    Console.WriteLine(item);
}

// Output:
// a
// b

A List behind one lock

There is no ConcurrentList, and this is why that is rarely a problem. Twelve lines give you a safe append, a safe snapshot, and the option to add a method that does two things at once later. A concurrent collection cannot offer that last one, so if your requirements are likely to grow past single operations, start here.

Example · csharp
using System.Threading;

public sealed class ResultLog
{
    private readonly Lock _gate = new();
    private readonly List<string> _lines = new();

    public void Add(string line)
    {
        lock (_gate)
        {
            _lines.Add(line);
        }
    }

    // Returns a copy, so callers read it without holding the lock and
    // without seeing later writes half applied.
    public string[] Snapshot()
    {
        lock (_gate)
        {
            return _lines.ToArray();
        }
    }
}

Discussion

  • Be the first to comment on this lesson.