Skip to Content
Course content

198: Interlocked Operations

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

I've seen this exact bug happen in production more times than I care to admit. Usually, it happens when a developer is trying to be "efficient" by avoiding a heavy lock statement for a simple counter. It looks innocent, but it's a ticking time bomb.

public class RequestCounter
{
    private int _count = 0;

    public void Increment()
    {
        _count++;
    }

    public int GetCount() => _count;
}

// Testing it with a few threads
var counter = new RequestCounter();
Parallel.For(0, 10000, i => 
{
    counter.Increment();
});

Console.WriteLine($"Expected: 10000, Actual: {counter.GetCount()}");

The Mystery of the Missing Requests

If you run the code above, you'll notice something frustrating: the actual count is almost never 10,000. You might get 9,432 or 8,110. It's non-deterministic; it changes every time you run it.

Here is what's happening under the hood: _count++ looks like a single operation, but it isn't. To the CPU, it's actually three distinct steps:

  • Read: Load the current value of _count from memory into a CPU register.
  • Increment: Add 1 to that register.
  • Write: Store the new value back into the memory location.

Imagine two threads hitting that line at the exact same time. Both read the value as 10. Both increment it to 11. Both write 11 back to memory. Two requests happened, but the counter only moved by one. We just "lost" an increment. This is a classic race condition.

Atomic Updates with Interlocked

You could wrap that increment in a lock block, but locks are relatively expensive. They involve the OS kernel and can put threads to sleep, which is overkill for a simple integer. Instead, we use the System.Threading.Interlocked class.

The Interlocked class provides atomic operations. "Atomic" comes from the Greek atomos, meaning "indivisible." An atomic operation happens as a single, uninterrupted unit of work at the hardware level. No other thread can sneak in halfway through the read-modify-write cycle.

public class RequestCounter
{
    private int _count = 0;

    public void Increment()
    {
        // This is now thread-safe and extremely fast
        Interlocked.Increment(ref _count);
    }

    public int GetCount() => _count;
}


Notice the ref keyword. Interlocked needs the actual memory address of the variable to ensure the CPU locks that specific memory bus during the operation.

Beyond Simple Addition

Incrementing is the most common use case, but Interlocked handles other critical patterns too. For example, Interlocked.Exchange lets you swap a value and get the old one back in one shot. This is incredibly useful for implementing "reset" logic or updating a shared state.

Then there is the "big gun": Interlocked.CompareExchange. This is the foundation of almost all lock-free programming. It says: "If the current value is X, change it to Y. If it's not X, do nothing. Either way, tell me what the value was before I tried."

int sharedValue = 0;
int expected = 0;
int newValue = 10;

// If sharedValue == 0, set it to 10.
// Returns the original value (which was 0).
int original = Interlocked.CompareExchange(ref sharedValue, newValue, expected);


I generally recommend using Interlocked whenever you're dealing with simple primitive counters or state flags. It's cleaner than a lock and significantly faster because it avoids the overhead of thread synchronization primitives.




📋 Practical Task

Build a Thread-Safe Global Sequence Generator

Imagine you are building a system that assigns a unique sequence number to every outgoing message across a multi-threaded application. You cannot use a lock because the volume of messages is too high and performance is critical.

Your Task:

  1. Create a class named SequenceGenerator.
  2. Implement a private integer field to track the current sequence.
  3. Implement a public method GetNextSequence() that uses Interlocked.Increment to return a unique, incrementing number to every caller.
  4. In your Main method, use Parallel.For to call GetNextSequence() 100,000 times.
  5. Store the results in a ConcurrentBag<int>.
  6. After the loop, verify that the maximum value in the bag is exactly 100,000 and that the bag contains 100,000 unique elements (you can use .Distinct().Count() for this).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.