Store buffers and reordering, full versus half fences, Interlocked.MemoryBarrier, implicit barriers in locks and Interlocked, double-checked locking and MemoryBarrierProcessWide.
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.
"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 withVolatile.Read(acquire) andVolatile.Write(release), and implicit fences inlockenter/exit, everyInterlockedoperation, 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,Volatilefor 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."
| 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 |
| 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().
| 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 |
| 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 |
| 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 |
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.
Staff-level check on low-level concurrency reasoning.
Green flags
Red flags
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.
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.
| 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).
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.
| 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 |
Volatile and re-run.Channel.CreateBounded<T>(new BoundedChannelOptions(1024) { SingleReader = true, SingleWriter = true }) and benchmark.Interlocked.MemoryBarrier() and Volatile.Write in SharpLab's JIT Asm view for x64 and Arm64.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.
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).
volatile where a full fence is requiredWhat 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.
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.
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.
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.
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.
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>.
MemoryBarrierProcessWideWhat 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.
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.
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.
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 |
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.
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.
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.
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.
Last updated 31 Oct 2026.