Atomic Operations with Interlocked in C

In concurrent programming, managing shared state across multiple threads without introducing performance bottlenecks is a common challenge. This article explores how atomic memory operations function in .NET, focusing on the System.Threading.Interlocked class. You will learn the mechanics behind hardware-level atomicity, how the .NET runtime translates these operations into CPU instructions, the role of memory barriers, and how to effectively use methods like Increment, Exchange, and CompareExchange to build lock-free, thread-safe code.

The Problem with Standard Memory Operations

A seemingly simple operation like count++ is not atomic. At the CPU level, an increment consists of three distinct steps:

  1. Read the current value from memory into a CPU register.
  2. Modify the value in the register (increment by one).
  3. Write the updated value back from the register to memory.

When multiple threads execute this sequence concurrently, their operations can interleave. If Thread A and Thread B read the value 5 simultaneously, both will calculate 6 and write it back, resulting in a lost update. While applying a C# lock statement resolves this issue, it introduces operating system thread synchronization overhead, kernel transitions, and potential thread blocking.

How Hardware Ensures Atomicity

Atomic operations guarantee that an action completes in a single, indivisible step from the perspective of any other thread or processor core. No thread can observe the operation in a partially completed state.

Modern CPU architectures achieve this using dedicated instructions rather than OS-level mutexes:

  • x86/x64 Architectures: Use the LOCK instruction prefix (e.g., LOCK XADD or LOCK CMPXCHG). Instead of physically locking the entire system bus as older processors did, modern CPUs utilize cache coherency protocols (such as MESI or MOESI). When an atomic instruction runs, the CPU secures exclusive ownership of the target cache line, updates it, and invalidates or updates stale copies in other cores' caches.
  • ARM Architectures: Often use Load-Link/Store-Conditional (LL/SC) instruction pairs (e.g., LDREX/STREX) or dedicated atomic instructions (in ARMv8.1-A and later) to detect whether another core modified the memory location before completing the write.

How the Interlocked Class Works in C

The System.Threading.Interlocked class provides a managed interface directly mapped by the Just-In-Time (JIT) compiler to these underlying hardware-level instructions. Because these instructions bypass managed monitors and kernel-level locks, they execute in single-digit CPU clock cycles without suspending threads.

Common Interlocked Methods

  • Interlocked.Increment and Interlocked.Decrement: Atomically increments or decrements a 32-bit or 64-bit integer and returns the resulting value.

    private static int _counter;
    
    public static void TrackHit()
    {
        Interlocked.Increment(ref _counter);
    }
  • Interlocked.Add: Atomically adds an integer to a target variable and returns the updated sum.

    Interlocked.Add(ref _totalBalance, depositAmount);
  • Interlocked.Exchange: Atomically overwrites a variable with a new value and returns the original value. This guarantees that writes are applied without being torn or interrupted.

    int oldValue = Interlocked.Exchange(ref _state, newState);
  • Interlocked.CompareExchange: The fundamental building block for lock-free algorithms, implementing the Compare-And-Swap (CAS) primitive. It compares the target variable with a specified expected value; if they match, it replaces the target with a new value.

    // Atomically update a shared state only if it hasn't changed
    int original;
    int computed;
    do
    {
        original = _resourceState;
        computed = CalculateNewState(original);
    } 
    while (Interlocked.CompareExchange(ref _resourceState, computed, original) != original);

Memory Barriers and Visibility

Modern CPUs and JIT compilers aggressively reorder read and write instructions to maximize throughput. Additionally, multi-core processors use store buffers, meaning a write made by one core might not immediately appear in the cache of another.

Interlocked methods address this by establishing full memory barriers (bidirectional fences). When an Interlocked call executes:

  1. Memory operations initiated before the call cannot be reordered to execute after it.
  2. Memory operations initiated after the call cannot be reordered to execute before it.
  3. Store buffers are flushed, ensuring the modified value is visible across all CPU caches immediately.

Performance Considerations

While Interlocked operations are significantly faster than locking constructs like Monitor, Mutex, or SemaphoreSlim, they are not free. Enforcing cache line exclusivity forces inter-core cache invalidations, which causes a performance penalty compared to plain memory reads and writes. They should be used specifically for synchronized critical points where lightweight, non-blocking synchronization is required.