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

📚 Interview question

What are memory barriers, why do CPUs and compilers reorder memory operations, and how does .NET expose barriers?

Store buffers and reordering, full versus half fences, Interlocked.MemoryBarrier, implicit barriers in locks and Interlocked, double-checked locking and MemoryBarrierProcessWide.

Staff / Architect Theory Expert concurrency lock-free memory-barriers memory-model

🎯 Key points

  • Barriers stop compiler and CPU reordering across them
  • Store buffers cause store-load reordering even on x64
  • Arm64 also reorders loads and stores more freely
  • Interlocked.MemoryBarrier is a full fence; Volatile gives half fences
  • lock and every Interlocked operation already include fences
  • Use Lazy<T> or LazyInitializer instead of hand-written double-checked locking
  • Prefer higher-level primitives; test lock-free code on Arm64

⏱ 30-second answer

A memory barrier (fence) stops the compiler and CPU from moving memory operations across it. Reordering happens because CPUs use store buffers, caches and out-of-order execution, and compilers move loads and stores to optimise; single-threaded results stay the same, but other threads can observe a different order. .NET exposes a full fence with Interlocked.MemoryBarrier() (and Thread.MemoryBarrier()), half fences with Volatile.Read/Write, and implicit full fences in lock enter/exit and every Interlocked operation. In practice you rarely write explicit barriers: use locks, Interlocked, Volatile, Lazy<T> and concurrent collections, which place them correctly.

🗣 Full answer (senior)

How I would answer this in an interview

"A memory barrier, or fence, is a point that memory operations aren't allowed to move across. Reordering exists because both compilers and CPUs optimise for single-threaded speed. The JIT hoists loads out of loops and reorders independent accesses. CPUs use store buffers, so a write can sit locally while the core runs ahead, and weaker architectures like Arm64 also reorder loads with loads and stores with stores. A single thread always sees its own program order, but other cores can observe a different order. .NET gives you a full fence with Interlocked.MemoryBarrier(), half fences with Volatile.Read (acquire) and Volatile.Write (release), and implicit fences in lock enter/exit, every Interlocked operation, task completion and thread start/join. At staff level, the point I'd stress is that you should almost never write an explicit barrier in application code. Use locks, Interlocked, Volatile for publication, Lazy<T>, concurrent collections and channels, which put the barriers in the right places. Then test any lock-free code under stress on Arm64."


1. Why reordering happens

Source What it does Visible to other threads as
JIT/compiler Hoists loads from loops, sinks/merges stores, reorders independent accesses Loops never seeing updates; out-of-order publication
Store buffer (all modern CPUs, including x64) Store waits in a per-core buffer while later loads execute Store → load reordering
Out-of-order execution / weak ordering (Arm64) Loads and stores to different addresses complete in any order Load→load, store→store, load→store reordering too
Caches Coherent, but ordering of visibility differs per core Combined with the above

2. Types of fences

Fence Prevents .NET
Full All reordering across it Interlocked.MemoryBarrier(), any Interlocked.*, lock enter/exit
Acquire (half) Later operations moving before it Volatile.Read, volatile field read
Release (half) Earlier operations moving after it Volatile.Write, volatile field write
Process-wide Forces every core running the process to serialise Interlocked.MemoryBarrierProcessWide()

Thread.MemoryBarrier() is the older equivalent of Interlocked.MemoryBarrier().

3. Barriers you already get

Construct Ordering guarantee
lock / Monitor / System.Threading.Lock Acquire on enter, release on exit
Interlocked.* Full fence
Volatile.Read/Write Acquire/release
Task completion → await continuation Writes before completion visible after await
Thread.Start / Join Parent's writes visible to child; child's writes visible after Join
Lazy<T>, LazyInitializer Safe publication
Concurrent collections, Channel<T> Correct internal ordering

4. Two classic patterns

Pattern Needs Why
Publish data then flag/reference Release on publish, acquire on observe Ensures data is visible before the flag
Dekker-style "I write my flag, then read yours" Full fence between store and load Store→load reordering lets both threads read 0

5. Decision ladder

Level Use
Application code lock, SemaphoreSlim, Channel<T>, concurrent collections, Lazy<T>
Infrastructure code Interlocked, Volatile publication, immutable snapshots
Specialist lock-free code Explicit MemoryBarrier, MemoryBarrierProcessWide, with stress tests on Arm64 and tools like Coyote

Production example

Our [telemetry ingestion service] used a custom single-producer/single-consumer ring buffer with plain int head and tail fields. After moving to [Arm64] hosts, we exported rare corrupted events containing default field values. Code review showed the producer wrote the slot and then advanced _tail with plain writes; on Arm64 the consumer could observe the new tail before the slot's data. I changed the producer to Volatile.Write(ref _tail, next) after writing the slot and the consumer to Volatile.Read(ref _tail) before reading slots (release/acquire). That fixed it with no measurable cost. Then I went further and replaced the custom buffer with Channel.CreateBounded<T> using SingleReader/SingleWriter, added an Arm64 stress test to CI, and restricted custom lock-free code to a small, reviewed infrastructure package.

🔍 What the interviewer is checking

Staff-level check on low-level concurrency reasoning.

Green flags

  • Explains store buffers and compiler reordering.
  • Distinguishes full and half fences and maps them to .NET APIs.
  • Knows locks and Interlocked already include barriers.
  • Recommends higher-level primitives and testing on weak memory models.

Red flags

  • Cannot explain why reordering happens.
  • Uses barriers as a cure-all.

📚 Learn in depth

1. Store buffers, concretely

Core 0                       Core 1
_flagA = 1  -> store buffer  _flagB = 1  -> store buffer
r0 = _flagB (from cache: 0)  r1 = _flagA (from cache: 0)
store buffers drain later    -> both r0 == 0 and r1 == 0 observed

Even x64, which keeps loads in order and stores in order, allows this one reordering. A full fence (lock-prefixed instruction / mfence, or dmb ish on Arm64) drains the store buffer before the load.

2. Publication with acquire/release (SPSC ring buffer)

public sealed class SpscRing<T>(int capacity)
{
    private readonly T[] _items = new T[capacity];
    private int _head;   // consumer-owned
    private int _tail;   // producer-owned

    public bool TryEnqueue(T item)
    {
        int tail = _tail;
        int next = (tail + 1) % _items.Length;
        if (next == Volatile.Read(ref _head)) return false;   // full (acquire: see consumer's progress)
        _items[tail] = item;                                   // write data
        Volatile.Write(ref _tail, next);                       // release: data visible before new tail
        return true;
    }

    public bool TryDequeue(out T item)
    {
        int head = _head;
        if (head == Volatile.Read(ref _tail)) { item = default!; return false; }   // acquire
        item = _items[head];
        _items[head] = default!;
        Volatile.Write(ref _head, (head + 1) % _items.Length);  // release slot back to producer
        return true;
    }
}

This is correct only for one producer and one consumer. That restriction is exactly why Channel<T> with SingleReader/SingleWriter is usually the better choice.

3. Cost of fences

Barrier Rough cost Notes
Volatile.Read/Write on x64 ~free (compiler barrier) x64 already orders loads/stores except store→load
Volatile.Read/Write on Arm64 Small (ldar/stlr)
Full fence / Interlocked Tens of cycles uncontended; much more under contention Drains store buffer; cache-line ownership
MemoryBarrierProcessWide Microseconds+ (OS call: FlushProcessWriteBuffers / membarrier) Interrupts all cores running the process

MemoryBarrierProcessWide exists for asymmetric designs: a very hot fast path that uses no fences, and a rare slow path that pays to synchronise all cores (some runtime internals and high-performance libraries use this trick).

4. A method for reasoning about handoffs

  1. List every variable shared between threads.
  2. For each handoff, identify the publish store and the observe load.
  3. Make publish a release and observe an acquire (or use a lock).
  4. Look for store-then-load of different variables that must not reorder: those need a full fence.
  5. Check atomicity separately: ordering doesn't make read-modify-write atomic.
  6. Write a stress test and run it on Arm64.

5. Double-checked locking in .NET

private Expensive? _instance;
public Expensive Instance =>
    LazyInitializer.EnsureInitialized(ref _instance, () => new Expensive());

Hand-written double-checked locking can be correct in .NET with a volatile/Volatile field, but it's easy to break during maintenance. Lazy<T> / LazyInitializer encode the right barriers once.

6. Version notes

Version Change
.NET Framework Thread.MemoryBarrier, Interlocked, volatile
.NET Framework 4.5 Volatile class
.NET Core 2.0 Interlocked.MemoryBarrierProcessWide
.NET Core 3.0 / .NET 5 Arm64 server support matures; memory model differences matter in production
.NET 7/8 era Written .NET memory model specification in the runtime repo
.NET 10 Same primitives

7. Try it yourself

  1. Run the Dekker code example many times; then uncomment the barriers and compare counts.
  2. Run it on an Arm64 machine and compare frequencies with x64.
  3. Implement the SPSC ring with plain writes, stress it on Arm64 with checksummed payloads, then add Volatile and re-run.
  4. Replace the ring with Channel.CreateBounded<T>(new BoundedChannelOptions(1024) { SingleReader = true, SingleWriter = true }) and benchmark.
  5. View Interlocked.MemoryBarrier() and Volatile.Write in SharpLab's JIT Asm view for x64 and Arm64.

💻 Code

using System.Threading;

// Store-load reordering demo (Dekker). Without the fence, both threads can read 0.
int bothSawZero = 0;
for (int i = 0; i < 200_000; i++)
{
    var t = new Trial();
    var a = Task.Run(t.A);
    var b = Task.Run(t.B);
    Task.WaitAll(a, b);
    if (t.SeenByA == 0 && t.SeenByB == 0) bothSawZero++;
}
Console.WriteLine($"Both saw zero (no fence): {bothSawZero}");

// Correct lazy initialisation without hand-written barriers.
Expensive? instance = null;
var value = LazyInitializer.EnsureInitialized(ref instance, () => new Expensive());
Console.WriteLine(value.CreatedAt);

public sealed class Trial
{
    private int _flagA, _flagB;
    public int SeenByA, SeenByB;

    public void A()
    {
        _flagA = 1;
        // Interlocked.MemoryBarrier(); // uncomment: store-load reordering prevented
        SeenByA = _flagB;
    }

    public void B()
    {
        _flagB = 1;
        // Interlocked.MemoryBarrier();
        SeenByB = _flagA;
    }
}

public sealed class Expensive
{
    public DateTime CreatedAt { get; } = DateTime.UtcNow;
}

The count depends on hardware and timing; the starting of tasks adds noise, so a zero result without the fence does not prove the code is correct.

⚠ Common mistakes

1. Assuming other threads see program order

What happens: code relies on "I wrote A before B, so everyone sees A first". Why it is wrong: compilers and CPUs reorder as observed by other cores. Do instead: use primitives that define ordering (locks, Volatile, Interlocked).

2. Using volatile where a full fence is required

What happens: Dekker-style flags with volatile fields. Why it is wrong: acquire/release doesn't stop store→load reordering. Do instead: Interlocked.MemoryBarrier(), an Interlocked operation, or a lock.

3. Sprinkling barriers "to be safe"

What happens: MemoryBarrier() calls added around shared accesses. Why it is wrong: hides the real design, costs performance, and doesn't fix atomicity bugs. Do instead: reason about each handoff or use higher-level primitives.

4. Confusing ordering with atomicity

What happens: a barrier is added around _count++. Why it is wrong: the increment is still a non-atomic read-modify-write. Do instead: Interlocked.Increment.

5. Hand-written double-checked locking

What happens: custom lazy singletons with subtle field access. Why it is wrong: easy to break during maintenance. Do instead: Lazy<T> or LazyInitializer.EnsureInitialized.

6. Testing lock-free code only on x64

What happens: code passes x64 tests. Why it is wrong: x64 hides most reorderings that Arm64 exposes. Do instead: stress tests on Arm64; tools like Coyote.

7. Using a lock-free structure with the wrong concurrency shape

What happens: an SPSC ring buffer is used by two producers. Why it is wrong: its ordering and ownership assumptions break. Do instead: document and enforce single-producer/single-consumer, or use Channel<T>.

8. Overusing MemoryBarrierProcessWide

What happens: called on a regular path. Why it is wrong: it interrupts all cores and is very expensive. Do instead: reserve it for rare slow paths in asymmetric designs.

9. Re-reading shared references

What happens: _config.A and later _config.B without capturing a local. Why it is wrong: may see two different published objects. Do instead: read once into a local after an acquire.

10. Ignoring the JIT

What happens: "x64 is strongly ordered, so we don't need volatile". Why it is wrong: the JIT itself can hoist and reorder accesses. Do instead: use Volatile/Interlocked/locks regardless of CPU architecture.

🏛 Senior insight: trade-offs

At staff level the value is knowing that barriers exist, being able to explain a reordering bug, and steering teams toward primitives that make barriers someone else's problem. I review any explicit barrier as a design smell unless it is in a small, well-tested infrastructure type.

Level Tool
Application code Locks, Channel<T>, concurrent collections, Lazy<T>
Infrastructure Interlocked, Volatile
Specialist lock-free code Explicit MemoryBarrier, MemoryBarrierProcessWide
Verification Stress tests on Arm64, tools such as Coyote

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions acquire and release Why: Checks volatile 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 says Interlocked is a full fence Why: Checks atomic operations
    How to 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.

  3. When: Candidate says locks include barriers Why: Checks lock semantics
    How to answer

    lock (x) { ... } gives mutual exclusion: only one thread at a time runs the block for the same lock object, and entering and leaving act as memory barriers. For a normal object the compiler emits Monitor.Enter(x, ref lockTaken) in a try/finally with Monitor.Exit. Since .NET 9 and C# 13, if x is a System.Threading.Lock, the compiler instead uses x.EnterScope() and disposes the scope, which is a dedicated, slightly faster lock type. Locks are re-entrant on the same thread, you cannot await inside one (CS1996), and you should lock on a private, readonly object and keep the block short.

🛠 Real-world problems: how you handle them

Ring buffer reading stale slots on Arm64
Situation
A [telemetry ingestion service] used a custom single-producer, single-consumer ring buffer with plain head and tail fields.
Signals
On [Arm64] hosts, rare corrupted events with default field values were exported.
Investigation
Code review showed the producer wrote the slot, then the tail index, with plain writes. On Arm64 the consumer could see the new tail before the slot data.
Resolution
Used `Volatile.Write` for the tail after writing the slot and `Volatile.Read` on the consumer; later replaced the custom buffer with `Channel<T>` with `SingleReader` and `SingleWriter` options.
Prevention
Added an Arm64 stress test and limited custom lock-free code to a reviewed infrastructure package.

How to tell it (STAR)

We saw [rare corrupted telemetry events] only on Arm64. Our custom ring buffer published the tail index with a plain write, so the consumer could see it before the data. I added release and acquire semantics with `Volatile`, then replaced the buffer with a single-reader channel. We now stress-test concurrency code on Arm64.

↩ Usually asked after

More in Synchronization

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me