Skip to Content
Course content

90: Thread Synchronization Primitives in .NET

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

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 OrderDispatcher class that holds a Queue<int> of Order IDs.
  • Implement a GetNextOrder() method. This method must use a SemaphoreSlim to 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 lock inside GetNextOrder to ensure that the actual Dequeue operation is thread-safe.
  • In your Main method, start three worker tasks that continuously call GetNextOrder and 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.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.