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
90: Thread Synchronization Primitives in .NET
I want to show you something that happens in almost every high-concurrency system: the "missing update" problem. We've talked about starting threads, but the real nightmare begins when those threads start fighting over the same piece of memory. Let's build a simple warehouse inventory tracker and see where it breaks.
The Mystery of the Vanishing Stock
I've written a quick Warehouse class. It's simple: it has a stock count, and a method to remove an item. I'll spin up 10 tasks, each trying to remove 100 items from a starting pool of 1,000. Mathematically, we should end up with zero.
public class Warehouse {
public int Stock = 1000;
public void RemoveItem() {
Stock--;
}
}
// In Main:
var warehouse = new Warehouse();
var tasks = Enumerable.Range(0, 10).Select(_ => Task.Run(() => {
for (int i = 0; i < 100; i++) warehouse.RemoveItem();
})).ToArray();
Task.WaitAll(tasks);
Console.WriteLine($"Final Stock: {warehouse.Stock}");
I ran this, and the result was Final Stock: 142. Then I ran it again, and it was 87. This is infuriating if you don't know why it's happening, but it's a classic race condition. Stock-- looks like one operation, but the CPU actually reads the value, decrements it in a register, and writes it back. If two threads read "1000" at the same time, they both write back "999", and one decrement effectively vanishes.
Putting a Guard at the Door
The most common way to fix this in C# is the lock keyword. It's essentially a "talking stick"—only the thread holding the lock can enter the critical section of code. I'll add a private object to act as the sync root (never lock on this or a string, as that can lead to deadlocks with external code).
public class Warehouse {
private readonly object _lockHandle = new();
public int Stock = 1000;
public void RemoveItem() {
lock (_lockHandle) {
Stock--;
}
}
}
Now it consistently hits zero. But lock is a "mutual exclusion" primitive; it's blunt. It stops everyone. What if we have a scenario where 50 threads are just checking the stock level (reading), and only one is updating it (writing)? Locking the whole method for readers is a massive waste of performance.
Optimizing for the Crowd
This is where ReaderWriterLockSlim comes in. It allows multiple readers to enter simultaneously, but if a writer wants in, it clears the room and gets exclusive access. I'll refactor our warehouse to separate the "Check" from the "Remove".
using System.Threading;
public class Warehouse {
private readonly ReaderWriterLockSlim _rwLock = new();
private int _stock = 1000;
public int GetStock() {
_rwLock.EnterReadLock();
try { return _stock; }
finally { _rwLock.ExitReadLock(); }
}
public void RemoveItem() {
_rwLock.EnterWriteLock();
try { _stock--; }
finally { _rwLock.ExitWriteLock(); }
}
}
Notice the try...finally block. This is non-negotiable. If your code throws an exception inside a lock and you don't call Exit, your application will hang forever because the lock is never released. I've seen entire production servers freeze because of a missing finally block.
Signaling Between Threads
Sometimes synchronization isn't about protecting a variable, but about orchestration. Imagine a "Restock" thread that needs to tell "Worker" threads, "Hey, the items are here, you can start picking now." Using a while(stock == 0) loop is called "busy waiting" and it kills your CPU.
I'll use a SemaphoreSlim for this. It's like a nightclub bouncer; it only lets a specific number of threads through. If the count is zero, the thread pauses (suspends) without eating CPU cycles until someone signals that a spot has opened up.
public class Warehouse {
private SemaphoreSlim _signal = new SemaphoreSlim(0); // Start at 0: no one enters
public async Task WaitForStockAsync() {
Console.WriteLine("Worker waiting for stock...");
await _signal.WaitAsync(); // Pauses here until Release() is called
Console.WriteLine("Stock arrived! Processing...");
}
public void Restock() {
Console.WriteLine("Restocking items...");
_signal.Release(); // Wakes up one waiting thread
}
}
Now we have a toolset: lock for quick mutual exclusion, ReaderWriterLockSlim for read-heavy data, and SemaphoreSlim for coordinating the flow of threads. Each has a cost, so don't wrap everything in a lock unless you've actually seen the race condition happening.
📋 Practical Task
Exercise: The Thread-Safe Order Dispatcher
You are building a dispatch system where multiple "Packer" threads are trying to take orders from a shared queue, but you must ensure that no two packers grab the same order, and packers must wait efficiently if the queue is empty.
Requirements:
- Create an
OrderDispatcherclass that holds aQueue<int>of Order IDs. - Implement a
GetNextOrder()method. This method must use aSemaphoreSlimto ensure that if the queue is empty, the calling thread waits asynchronously until an order is added. - Implement an
AddOrder(int orderId)method that adds an order to the queue and signals the semaphore. - Use a
lockinsideGetNextOrderto ensure that the actualDequeueoperation is thread-safe. - In your
Mainmethod, start three worker tasks that continuously callGetNextOrderand print which worker got which order. Then, after a short delay, add five orders to the dispatcher and observe the workers waking up to process them.
There are no comments for now.