Skip to Content
Course content

91: Concurrent Collections in .NET

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you've spent any time working with multi-threaded code in C#, you've likely hit that moment where you realize your collection is lying to you. You start with a standard Dictionary or List, you spin up a few tasks to populate it, and suddenly you're staring at an InvalidOperationException telling you that the collection was modified while you were iterating over it. Or worse, you get silent data corruption where a value just... disappears.

The "Big Lock" Bottleneck

The first instinct for most developers—and it's the one I used for years—is to just wrap every access to the collection in a lock statement. It looks something like this. Imagine we're building a shared cache that tracks the last time a user was active in our system:

private readonly Dictionary<int, DateTime> _userActivity = new();
private readonly object _syncRoot = new();

public void UpdateActivity(int userId)
{
    lock (_syncRoot)
    {
        _userActivity[userId] = DateTime.UtcNow;
    }
}

public DateTime? GetLastActive(int userId)
{
    lock (_syncRoot)
    {
        return _userActivity.TryGetValue(userId, out var time) ? time : null;
    }
}

On the surface, this is perfectly safe. No race conditions, no crashes. But here is the rub: you've created a massive bottleneck. Every single thread—whether it's trying to read a value or update one—has to wait in line for that single _syncRoot object. In a high-throughput environment, your CPU cores will spend more time parked, waiting for the lock, than actually doing work. You've essentially turned your multi-threaded application back into a single-threaded one whenever it touches this cache.

Fine-Grained Concurrency

This is why System.Collections.Concurrent exists. Instead of you managing the locks manually, these collections handle the synchronization internally using much more sophisticated techniques. For our cache, we'd swap that Dictionary for a ConcurrentDictionary.

private readonly ConcurrentDictionary<int, DateTime> _userActivity = new();

public void UpdateActivity(int userId)
{
    // No lock needed!
    _userActivity[userId] = DateTime.UtcNow;
}

public DateTime? GetLastActive(int userId)
{
    return _userActivity.TryGetValue(userId, out var time) ? time : null;
}

Now, why is this better? A ConcurrentDictionary doesn't just lock the whole table. It uses a technique called "lock striping." It divides the collection into buckets, each with its own lock. If Thread A is updating User 1 and Thread B is updating User 100, they're likely hitting different buckets and can proceed simultaneously without blocking each other. Even better, reads are generally lock-free, meaning your GetLastActive calls won't stop your UpdateActivity calls from happening.

The Trap of Non-Atomic Logic

Here is where I want you to be careful. Just because the collection is "thread-safe" doesn't mean your logic is. A common mistake I see is the "check-then-act" pattern. You might be tempted to do something like this:

if (!_userActivity.ContainsKey(userId)) 
{
    _userActivity.TryAdd(userId, DateTime.UtcNow);
}

This is a classic race condition. Between the time you check ContainsKey and the time you call TryAdd, another thread could have slipped in and added that user. You've reintroduced the very bug these collections were meant to solve.

To fix this, you have to use the atomic methods provided by the API. Instead of checking and then adding, you use GetOrAdd or AddOrUpdate. These methods guarantee that the operation happens as a single, unbreakable unit of work. It forces you to think about the operation as a transformation of the state rather than a series of steps, which is exactly how you write robust concurrent code.




📋 Practical Task

Exercise: Building a Thread-Safe Global Request Tracker

You are tasked with creating a RequestTracker class that monitors how many times different API endpoints are hit across multiple concurrent threads.

Requirements:

  • Create a class RequestTracker that uses a ConcurrentDictionary<string, int> to store the endpoint URL as the key and the hit count as the value.
  • Implement a method public void LogRequest(string url). This method must increment the count for the given URL. Crucially, you must use AddOrUpdate to ensure the increment is atomic and thread-safe.
  • Implement a method public int GetHitCount(string url) that returns the current count for a URL, or 0 if it has never been hit.
  • In your Main method, simulate high concurrency by starting 10 Task objects. Each task should call LogRequest for the same URL (" /api/home") 1,000 times.
  • After all tasks have completed, print the final count for "/api/home". It should be exactly 10,000.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.