Atomic Increment, Add, Exchange, CompareExchange, Read, And and Or; full fences; CAS loops; ABA and when lock-free is worth it.
Interlocked provides atomic operations on shared variables: Increment, Decrement, Add, Exchange, CompareExchange, Read (for 64-bit values on 32-bit platforms), and And/Or (.NET 5+). Each is a single indivisible hardware operation and a full memory fence. CompareExchange(ref x, newValue, expected) writes only if x still equals expected and returns the original value, which is the building block for lock-free algorithms: read, compute, try to swap, and retry if another thread changed it. Use it for counters, flags and reference swaps; use a lock when several values must change together.
"
Interlockedgives you atomic operations on a single shared variable:Increment,Decrement,Add,Exchange,CompareExchange,Read(for 64-bit values on 32-bit platforms), andAnd/Or. Each one is a single indivisible hardware instruction and a full memory fence. That's whyInterlocked.Increment(ref x)is safe wherex++loses updates:x++is read, add, write, and two threads can interleave between those steps. The key primitive isCompareExchange(ref location, newValue, comparand): it writesnewValueonly iflocationstill equalscomparand, and always returns the original value. Lock-free updates are built from it as a CAS loop: read the current value, compute the new one from it, try to swap, and if another thread changed it first, reread and retry. I use it for counters, flags, once-only initialisation and swapping references to immutable objects. When several values must change together, I use a lock, or put them in one immutable object and CAS the reference."
x++ is unsafeThread A: read x (5) Thread B: read x (5)
Thread A: add 1 -> 6 Thread B: add 1 -> 6
Thread A: write 6 Thread B: write 6 // one increment lost
volatile doesn't fix this: it controls visibility/ordering, not atomicity of read-modify-write.
| Method | Returns | Use |
|---|---|---|
Increment(ref x) / Decrement |
New value | Counters, reference counts |
Add(ref x, n) |
New value | Accumulators |
Exchange(ref x, v) |
Old value | Take ownership (swap a buffer out, set a flag and learn the previous state) |
CompareExchange(ref x, v, expected) |
Original value | Conditional update; CAS loops; once-only init |
Read(ref long) |
Value | Atomic 64-bit read on 32-bit platforms |
And / Or (.NET 5+) |
Original value | Atomic flag bits |
MemoryBarrier() |
— | Explicit full fence |
Success test for CAS: Interlocked.CompareExchange(ref x, next, current) == current.
public void RecordMax(int value)
{
int current;
do
{
current = Volatile.Read(ref _max); // 1. read fresh
if (value <= current) return; // 2. compute from fresh value (here: maybe nothing to do)
}
while (Interlocked.CompareExchange(ref _max, value, current) != current); // 3. try; 4. retry if changed
}
Interlocked can't do| Limitation | Consequence | Use instead |
|---|---|---|
| One memory location at a time | Two related fields can be seen half-updated | lock, or an immutable object swapped via CAS |
| Contention on one variable | Cache line ping-pongs between cores; throughput collapses | Per-thread/per-core counters aggregated on read; metrics APIs |
| ABA | Value goes A→B→A; CAS succeeds when it shouldn't | Version stamps, immutable objects (GC makes ABA rare for references), avoid pooled-node lock-free structures |
| Complex invariants | Lock-free correctness is hard to prove/test | lock |
| State | Tool |
|---|---|
| Request/event counter | Interlocked.Increment or a System.Diagnostics.Metrics Counter<T> |
| "Run once" / "dispose once" flag | CompareExchange(ref flag, 1, 0) == 0 |
| Swap a reference to new immutable state | Volatile.Write (single writer) or CAS loop (many writers) |
| Immutable collection update | ImmutableInterlocked.Update / AddOrUpdate |
| Very hot counter on many cores | Striped counters, padded to avoid false sharing |
Our [traffic dashboard] counted requests with _count++ from many request threads. In a load test, our numbers came out [about 8%] lower than the load generator reported. Code review found the non-atomic increment, and a unit test with 8 threads doing 1,000,000 increments each reproduced the lost updates deterministically. I switched to Interlocked.Increment (and Interlocked.Read for the 64-bit read), which matched the load generator exactly. Later we replaced the hand-rolled counter with a System.Diagnostics.Metrics Counter<long> exported through OpenTelemetry, which handles concurrency internally. We kept the multi-threaded test and added "shared mutable fields" to the review checklist.
Checks atomicity knowledge and lock-free reasoning.
Green flags
++ is unsafe.Interlocked operations are full fences.Red flags
volatile makes ++ atomic.| .NET call | x64 | Arm64 (with LSE atomics) |
|---|---|---|
Interlocked.Increment(ref int) |
lock xadd |
ldaddal |
Interlocked.CompareExchange(ref int, ...) |
lock cmpxchg |
casal |
Interlocked.Exchange(ref int, ...) |
xchg (implicitly locked) |
swpal |
On older Arm64 without LSE, the runtime uses load-exclusive/store-exclusive loops (ldaxr/stlxr). All Interlocked operations act as full fences: no reads or writes move across them in either direction.
The lock prefix makes the core hold the cache line exclusively for the read-modify-write. That's why contended atomics are expensive: the line must travel between cores' caches.
public sealed record Stats(long Count, int Max, DateTime Last);
private Stats _stats = new(0, 0, DateTime.MinValue);
public void Record(int latency)
{
Stats current, next;
do
{
current = Volatile.Read(ref _stats);
next = current with
{
Count = current.Count + 1,
Max = Math.Max(current.Max, latency),
Last = DateTime.UtcNow,
};
}
while (Interlocked.CompareExchange(ref _stats, next, current) != current);
}
Readers always see a consistent Stats object. The cost is one allocation per update and retries under contention, so for very hot paths a lock may actually be faster. Measure.
private ExpensiveClient? _client;
public ExpensiveClient Client
{
get
{
var c = Volatile.Read(ref _client);
if (c is not null) return c;
var created = new ExpensiveClient();
var winner = Interlocked.CompareExchange(ref _client, created, null) ?? created;
if (!ReferenceEquals(winner, created)) created.Dispose(); // lost the race
return winner;
}
}
This may construct more than one instance under a race (losers are disposed). If construction must happen exactly once, use Lazy<T> (ExecutionAndPublication) or LazyInitializer.EnsureInitialized with a lock object.
A lock-free stack using a pooled node: thread 1 reads head = A (next = B), gets preempted; thread 2 pops A, pops B, pushes A back (reused). Thread 1's CAS(head, B, A) succeeds, setting head to B, a node no longer in the stack. In .NET, allocating new nodes and letting the GC reclaim them avoids this for references, because A can't be reused while thread 1 still references it. Pooling nodes or using integer indices brings ABA back; version counters (tagged values) fix it.
1 counter, 32 cores incrementing: throughput limited by cache-line transfers
32 padded counters (one per core): near-linear scaling; sum on read (slightly stale but consistent enough)
System.Diagnostics.Metrics instruments and many BCL counters already use this kind of aggregation.
| Version | Change |
|---|---|
| .NET Framework 1.0+ | Increment, Decrement, Exchange, CompareExchange |
| .NET Framework 2.0 | Add, Read, generic CompareExchange<T> for reference types |
| .NET 5 | And, Or |
| .NET 9 | Wider type support for Exchange/CompareExchange (smaller integer types, enums/primitives via generics) |
| .NET 10 | Same semantics |
Interlocked.Increment with _count++ and compare totals.Stats swap and compare throughput with a lock version.Interlocked.Exchange(ref _buffer, null) to take ownership of a buffer exactly once across threads.Interlocked.Increment.using System.Threading;
var stats = new RequestStats();
await Task.WhenAll(Enumerable.Range(0, 8).Select(i => Task.Run(() =>
{
var rnd = new Random(i);
for (int n = 0; n < 100_000; n++) stats.Record(rnd.Next(1, 1000));
})));
Console.WriteLine($"Count {stats.Count}, max latency {stats.MaxLatency}");
var once = new OnceFlag();
int winners = 0;
Parallel.For(0, 100, _ => { if (once.TryClaim()) Interlocked.Increment(ref winners); });
Console.WriteLine($"Winners: {winners}"); // 1
public sealed class RequestStats
{
private long _count;
private int _maxLatency;
public long Count => Interlocked.Read(ref _count);
public int MaxLatency => Volatile.Read(ref _maxLatency);
public void Record(int latencyMs)
{
Interlocked.Increment(ref _count);
int current;
do
{
current = Volatile.Read(ref _maxLatency);
if (latencyMs <= current) return;
}
while (Interlocked.CompareExchange(ref _maxLatency, latencyMs, current) != current);
}
}
public sealed class OnceFlag
{
private int _claimed;
public bool TryClaim() => Interlocked.CompareExchange(ref _claimed, 1, 0) == 0;
}
++ or += on shared fieldsWhat happens: _count++ from multiple threads.
Why it is wrong: read-modify-write isn't atomic; updates are lost.
Do instead: Interlocked.Increment / Add.
volatile makes ++ atomicWhat happens: volatile int _count; _count++;.
Why it is wrong: volatile affects visibility/ordering, not atomicity.
Do instead: Interlocked.
What happens: Interlocked.Increment(ref _count); Interlocked.Add(ref _sum, x);.
Why it is wrong: readers can see a count without the matching sum.
Do instead: a lock, or one immutable object swapped with CAS.
What happens: the new value is computed once outside the loop. Why it is wrong: overwrites another thread's update with stale data. Do instead: reread and recompute on every iteration.
CompareExchange's return valueWhat happens: code treats the return value as the new value or a bool.
Why it is wrong: it returns the original value; success means it equals the comparand.
Do instead: if (Interlocked.CompareExchange(ref x, next, current) == current).
What happens: a global atomic counter on the hottest path. Why it is wrong: cache-line contention limits scalability. Do instead: striped/per-thread counters or metrics instruments.
What happens: reading a long updated with Interlocked.Add via a plain read.
Why it is wrong: torn reads on 32-bit.
Do instead: Interlocked.Read (or Volatile.Read on 64-bit-only code where atomic alignment is guaranteed).
What happens: a custom lock-free queue in application code.
Why it is wrong: ABA, memory ordering and progress bugs are subtle and rare to reproduce.
Do instead: ConcurrentQueue<T>, Channel<T>, or a lock.
What happens: CompareExchange(ref _x, new X(), null) with a side-effecting constructor.
Why it is wrong: multiple instances can be constructed under races.
Do instead: Lazy<T> with ExecutionAndPublication.
What happens: each retry does allocation or I/O. Why it is wrong: contention multiplies wasted work. Do instead: keep the computation tiny, or use a lock.
Interlocked is the right tool for single-word state and the wrong tool for invariants. I am comfortable writing CAS loops, but I keep them small and wrap them in well-tested types so the rest of the code never sees them.
| State | Tool |
|---|---|
| Counter | Interlocked.Increment or Metrics counter |
| Flag or once-only | CompareExchange |
| Multi-field state | Immutable object + CAS, or lock |
| Immutable collection update | ImmutableInterlocked.Update |
| Very hot counter | Per-core counters aggregated |
volatile (or Volatile.Read/Volatile.Write) tells the JIT and CPU not to cache or reorder a field access in unsafe ways. A volatile read has acquire semantics (later memory operations cannot move before it) and a volatile write has release semantics (earlier operations cannot move after it). This makes "publish data, then set a flag" patterns work across threads. It does not make compound operations atomic (volatile int x; x++ still loses updates) and it is not a full fence. Interlocked gives atomicity plus a full fence. Volatile matters more on Arm64, whose memory model is weaker than x64.
Cores keep their caches coherent: when one core writes to a cache line, copies of that whole 64-byte line in other cores are invalidated. If thread A updates counters[0] and thread B updates counters[1], they're logically independent but sit on the same line, so the line ping-pongs between cores on every write. That's false sharing: more threads, more invalidations, and the loop gets slower. Symptoms are high CPU with poor scaling and no locks in sight. The best fix is to accumulate in a local variable per thread and combine once at the end (Parallel.For with localInit/localFinally). Where per-thread state must live in shared memory, pad each slot to its own cache line (128 bytes is safe across x64 and Apple Arm64).
SpinLock is a struct lock that busy-waits (spins) instead of blocking the thread, which avoids a context switch when the lock is held for only a few instructions. It is not re-entrant, it must never be copied (store it in a non-readonly field and pass it by ref), and it wastes CPU if held longer than a tiny moment or if the owner is descheduled. Monitor and Lock already spin briefly before blocking, so SpinLock only helps in very hot, very short critical sections proven by profiling. Most code should use lock.
How to tell it (STAR)
Our [traffic dashboard] under-counted during load tests. I found `_count++` on a shared field and wrote a test that showed lost updates. Switching to `Interlocked.Increment` fixed it, and we later moved to the built-in metrics API.
Last updated 31 Oct 2026.