📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What does the Interlocked class do, and how do you build a lock-free update with CompareExchange?

Atomic Increment, Add, Exchange, CompareExchange, Read, And and Or; full fences; CAS loops; ABA and when lock-free is worth it.

Senior Coding Advanced atomic compare-and-swap interlocked lock-free

🎯 Key points

  • Interlocked operations are atomic and act as full memory fences
  • x++ is read-modify-write and loses updates under concurrency
  • CompareExchange writes only if the value matches and returns the original
  • CAS loops reread, recompute and retry
  • Only one memory location is atomic; use a lock or immutable swap for invariants
  • Heavy contention on one variable hurts scalability
  • ABA is possible with reused values or pooled objects

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"Interlocked gives you atomic operations on a single shared variable: Increment, Decrement, Add, Exchange, CompareExchange, Read (for 64-bit values on 32-bit platforms), and And/Or. Each one is a single indivisible hardware instruction and a full memory fence. That's why Interlocked.Increment(ref x) is safe where x++ loses updates: x++ is read, add, write, and two threads can interleave between those steps. The key primitive is CompareExchange(ref location, newValue, comparand): it writes newValue only if location still equals comparand, 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."


1. Why x++ is unsafe

Thread 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.

2. The operations

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.

3. The CAS loop pattern

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
}

4. What 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

5. Where I use it

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

Production example

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.

🔍 What the interviewer is checking

Checks atomicity knowledge and lock-free reasoning.

Green flags

  • Explains why ++ is unsafe.
  • Writes a correct CAS loop.
  • Knows Interlocked operations are full fences.
  • Discusses single-location limits, contention and ABA.

Red flags

  • Thinks volatile makes ++ atomic.
  • Uses several Interlocked calls to update an invariant.

📚 Learn in depth

1. What the hardware does

.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.

2. Swapping immutable state (multi-field invariants)

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.

3. Lazy, thread-safe initialisation

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.

4. ABA, concretely

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.

5. Contention and striping

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.

6. Version notes

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

7. Try it yourself

  1. Run the code example; then replace Interlocked.Increment with _count++ and compare totals.
  2. Write a CAS loop that doesn't re-read inside the loop and observe incorrect results under contention.
  3. Benchmark one shared counter vs 8 padded per-thread counters with 8 threads.
  4. Implement the immutable Stats swap and compare throughput with a lock version.
  5. Use Interlocked.Exchange(ref _buffer, null) to take ownership of a buffer exactly once across threads.
  6. Look at the generated assembly in SharpLab (JIT Asm view) for Interlocked.Increment.

💻 Code

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;
}

⚠ Common mistakes

1. ++ or += on shared fields

What happens: _count++ from multiple threads. Why it is wrong: read-modify-write isn't atomic; updates are lost. Do instead: Interlocked.Increment / Add.

2. Believing volatile makes ++ atomic

What happens: volatile int _count; _count++;. Why it is wrong: volatile affects visibility/ordering, not atomicity. Do instead: Interlocked.

3. Updating related fields with separate atomic calls

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.

4. CAS loops that don't recompute from the fresh value

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.

5. Misreading CompareExchange's return value

What 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).

6. Hammering one counter from all cores

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.

7. Non-atomic 64-bit reads on 32-bit platforms

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).

8. Building complex lock-free structures by hand

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.

9. Using CAS-based lazy init where construction must be exactly once

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.

10. Spinning in a CAS loop with heavy work per attempt

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate uses Volatile.Read in the CAS loop Why: Checks acquire and release semantics
    How to answer

    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.

  2. When: Candidate mentions contention on one cache line Why: Checks false sharing
    How to answer

    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).

  3. When: Candidate talks about spinning Why: Probes SpinLock judgement
    How to answer

    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.

🛠 Real-world problems: how you handle them

Lost updates in a request counter
Situation
A [traffic dashboard] counted requests with `_count++` from many threads.
Signals
Load tests showed counts [about 8%] lower than the load generator reported.
Investigation
Code review found the non-atomic increment; a unit test with 8 threads reproduced the loss.
Resolution
Switched to `Interlocked.Increment`, then migrated to a `System.Diagnostics.Metrics` counter.
Prevention
Added the multi-threaded test and a review checklist item for shared mutable fields.

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.

↩ Usually asked after

More in Synchronization

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me