C#
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Object-Oriented C#
-
Section 4: Working with Data
-
Section 5: Error Handling
-
Section 6: Delegates and Events
-
Section 7: Async Programming
-
Section 8: More Language Features
-
Section 9: File I/O and Serialization
-
Section 10: Networking in .NET
-
Section 11: The .NET Ecosystem
-
Section 12: Memory and Performance
-
Section 13: Concurrency Beyond Async
-
Section 14: Reflection and Attributes
-
Section 15: Testing and Best Practices
-
Section 16: Design Patterns in C#
-
Section 17: Standard Library Deep Dive
-
Section 18: Data Structures and Algorithms in C#
-
Section 19: GUI and Desktop Development Overview
-
Section 20: Practical Projects
-
Section 21: More Practice Exercises
-
Section 22: More Standard Library and Text Processing
-
Section 23: More Design Patterns
-
Section 24: More Projects
-
Section 25: Interview Practice
-
Section 26: C# Keywords Reference (Modifiers)
-
Section 27: C# Keywords Reference (Statements)
-
Section 28: C# Keywords Reference (Operators)
-
Section 29: BCL: System.Collections.Generic
-
Section 30: BCL: System.Linq
-
Section 31: BCL: System.Threading
-
Section 32: BCL: System.IO
-
Section 33: BCL: System.Text and System.Text.Json
-
Section 34: BCL: System.Net.Http
-
Section 35: C# Language Specification Topics
-
Section 36: More Practice Exercises
-
Section 37: More Async Patterns
-
Section 38: More BCL: System.Reflection and System.Diagnostics
-
Section 39: Nullable Reference Types In Depth
-
Section 40: C# Records and Pattern Matching In Depth
-
Section 41: Dependency Injection Deep Dive
-
Section 42: More Interview and Whiteboard Practice
91: Concurrent Collections in .NET
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
RequestTrackerthat uses aConcurrentDictionary<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 useAddOrUpdateto 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
Mainmethod, simulate high concurrency by starting 10Taskobjects. Each task should callLogRequestfor 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.
There are no comments for now.