CodeGym /Courses /C# SELF /Introduction to Concurrent...

Introduction to Concurrent collections

C# SELF
Level 58 , Lesson 0
Available

1. Background of the problem

In a single-threaded app collections like List<T>, Dictionary<T> behave predictably. But once multiple threads start accessing the same collection at the same time, a familiar problem appears: race conditions.

If several threads try to read and/or write to the same collection without proper synchronization, you can get:

  • Incorrect data: an item could be removed by one thread while another was trying to update it.
  • Data loss: one thread added an item and another overwrote it without knowing about the previous write.
  • Exceptions: the collection can end up in an invalid state, and you'll get InvalidOperationException (for example, "Collection was modified; enumeration operation may not execute.") or even NullReferenceException.

Example 1: Race in List<T> (simple increment)

Two threads increment the same element of the list at the same time.

using System.Collections.Generic;
using System.Threading.Tasks; // For Task.Run

class RaceConditionExample
{
    static List<int> numbers = new List<int> { 0 }; // List with one element

    static void Main(string[] args)
    {
        Console.WriteLine("Initial value: " + numbers[0]); // 0

        // Start two threads, each incrementing numbers[0]
        Task task1 = Task.Run(() => IncrementNumbers(500_000));
        Task task2 = Task.Run(() => IncrementNumbers(500_000));

        Task.WaitAll(task1, task2); // Wait for both threads

        Console.WriteLine("Final value: " + numbers[0]); // Expect 1_000_000, but...
        // The result will almost always be less than 1_000_000!
    }

    static void IncrementNumbers(int count)
    {
        for (int i = 0; i < count; i++)
        {
            // This operation "numbers[0]++" actually consists of 3 steps:
            // 1. Read numbers[0]
            // 2. Increment the value by 1
            // 3. Write the new value back to numbers[0]
            numbers[0]++; 
        }
    }
}

Why is this a race? If thread A reads numbers[0] (value 0), and then thread B reads numbers[0] (also 0) before A writes 1, both threads will increment 0 to 1 and write 1. One increment is lost. The operation numbers[0]++ is not atomic.

Example 2: InvalidOperationException when modifying a Dictionary

One thread iterates the dictionary, another modifies it.

using System.Collections.Generic;
using System.Threading; // For Thread.Sleep

class DictionaryRaceExample
{
    static Dictionary<int, string> users = new Dictionary<int, string>();

    static void Main(string[] args)
    {
        // Initialize the dictionary
        for (int i = 0; i < 5; i++) users.Add(i, $"User {i}");

        // Reader thread
        Thread readerThread = new Thread(() =>
        {
            try
            {
                foreach (var user in users) // Iterate over the dictionary
                {
                    Console.WriteLine($"Reader: {user.Key} - {user.Value}");
                    Thread.Sleep(10); // Simulate work
                }
            }
            catch (InvalidOperationException ex)
            {
                Console.WriteLine($"Reader: ERROR! {ex.Message}");
            }
        });

        // Writer thread
        Thread writerThread = new Thread(() =>
        {
            Thread.Sleep(5); // Give the reader a bit of time to start
            for (int i = 5; i < 10; i++)
            {
                users.Add(i, $"New User {i}"); // Add items
                Console.WriteLine($"Writer: Added User {i}");
                Thread.Sleep(15);
            }
        });

        readerThread.Start();
        writerThread.Start();

        readerThread.Join(); // Wait for threads to finish
        writerThread.Join();
        Console.WriteLine("Example finished.");
    }
}

Why does the error happen? Dictionary<TKey, TValue> (like List<T>) is not designed for concurrent reads and writes from different threads without synchronization. When the writer thread changes the internal structure, the reader continues the foreach over already-modified data, which leads to InvalidOperationException.

2. Why simple locks (lock) aren't always optimal?

The idea of "wrap everything in a lock" looks simple, but has downsides:

// Bad example: too much locking
// (Only for demonstration, don't do this!)
static object _lock = new object();
static List<int> _sharedList = new List<int>();

void AddItem(int item)
{
    lock (_lock)
    {
        _sharedList.Add(item);
    }
}

int GetItemCount()
{
    lock (_lock)
    {
        return _sharedList.Count;
    }
}
  • Performance (bottleneck): lock blocks access to the entire collection. With 100 threads, 99 will wait for one, even if operations don't directly conflict.
  • Complexity: you must remember to use lock everywhere the collection is used. One forgotten spot — and the race is back.
  • Deadlocks: multiple lock on different objects can easily lead to deadlock.
  • Iterators: foreach doesn't save you if another thread modifies the collection.

That's why .NET introduced specialized thread-safe collections.

Atomic operations

A thread-safe collection guarantees correct behavior under concurrent access from multiple threads without external locks from the user. The key is atomic operations: the action either happens completely or not at all — other threads don't see "half-states".

  • Add, remove, read — behave as if executed one at a time.
  • Under the hood they use low-level techniques: interlocked operations (Interlocked), Compare-And-Swap (CAS), fine-grained locks — instead of a global lock on the whole collection.

3. Overview of System.Collections.Concurrent

The namespace System.Collections.Concurrent provides a set of collections built from the ground up for multithreading. Their philosophy is maximum parallelism and minimal locking.

  • Performance: they scale with the number of cores.
  • Simplicity: you don't need to manually put lock around every operation.
  • Fewer bugs: a whole class of manual-synchronization errors disappears.
  • Optimized for contention: they work efficiently under concurrent adds/removes.

4. Main classes

ConcurrentQueue<T> (thread-safe queue)

Principle: FIFO — first in, first out. Scenarios: producer–consumer, logging, task queues.

using System.Collections.Concurrent;

ConcurrentQueue<string> messageQueue = new ConcurrentQueue<string>();

void Producer() => messageQueue.Enqueue("Message 1");

void Consumer()
{
    if (messageQueue.TryDequeue(out string message))
    {
        Console.WriteLine($"Processed: {message}");
    }
    else
    {
        Console.WriteLine("Queue is empty.");
    }
}

ConcurrentStack<T> (thread-safe stack)

Principle: LIFO — last in, first out. Scenarios: action history, DFS traversal, object pools.

using System.Collections.Concurrent;

ConcurrentStack<int> historyStack = new ConcurrentStack<int>();

void PushAction(int value) => historyStack.Push(value);

void PopAction()
{
    if (historyStack.TryPop(out int action))
    {
        Console.WriteLine($"Undone action: {action}");
    }
    else
    {
        Console.WriteLine("Stack is empty.");
    }
}

ConcurrentBag<T> (thread-safe "bag")

An unordered collection — order is not guaranteed. Optimized for the scenario "a thread often takes what it put itself". Great for pools.

using System.Collections.Concurrent;

ConcurrentBag<System.Guid> objectPool = new ConcurrentBag<System.Guid>();

void AddObject() => objectPool.Add(System.Guid.NewGuid());

void TakeObject()
{
    if (objectPool.TryTake(out System.Guid obj))
    {
        Console.WriteLine($"Took object: {obj}");
    }
    else
    {
        Console.WriteLine("Pool is empty.");
    }
}

ConcurrentDictionary<TKey, TValue> (thread-safe dictionary)

Supports atomic add, update and get-by-key operations. Great for caches, sessions, counters.

using System.Collections.Concurrent;

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

void UpdateScore(string user, int score)
{
    // Atomically add if missing, or update if present
    userScores.AddOrUpdate(user, score, (key, existingVal) => existingVal + score);
    Console.WriteLine($"Score {user}: {userScores[user]}");
}

void GetScore(string user)
{
    if (userScores.TryGetValue(user, out int score))
    {
        Console.WriteLine($"Current score {user}: {score}");
    }
    else
    {
        Console.WriteLine($"User {user} not found.");
    }
}

5. When to use these collections instead of the regular ones?

  • The app is multithreaded: with a single thread regular collections are faster (no overhead).
  • One shared collection for multiple threads: a key sign to use System.Collections.Concurrent.
  • You need high performance and scalability: the collections are designed for minimal waiting.
  • You want simpler code: no manual lock blocks around every operation.
  • You need atomic operations: add/remove/get won't leave the collection in an inconsistent state.

Don't use Concurrent collections when:

  • The application is strictly single-threaded.
  • You need "transactionality" across multiple related operations (external synchronization or other mechanisms might be required).
  • Strict retrieval order matters where it's not guaranteed (for example, in ConcurrentBag<T>).
2
Task
C# SELF, level 58, lesson 0
Locked
Adding elements to a ConcurrentQueue
Adding elements to a ConcurrentQueue
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION