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:
- Read the current value from memory into a CPU register.
- Modify the value in the register (increment by one).
- 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
LOCKinstruction prefix (e.g.,LOCK XADDorLOCK 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.IncrementandInterlocked.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:
- Memory operations initiated before the call cannot be reordered to execute after it.
- Memory operations initiated after the call cannot be reordered to execute before it.
- 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.