Compiler and CPU reordering, acquire and release semantics, the volatile keyword, Volatile class, x64 versus Arm64 memory models and what volatile does not guarantee.
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.
"
volatile, or the explicitVolatile.ReadandVolatile.Writemethods, solves two problems with sharing a field between threads. The JIT may keep a value in a register so a loop never sees another thread's write, and the compiler or CPU may reorder independent memory operations. A volatile read has acquire semantics: later reads and writes can't move before it. A volatile write has release semantics: earlier reads and writes can't move after it. That's exactly what's needed to publish data safely: write the data, then do a volatile write of a flag or reference; the reader does a volatile read and then sees the data. What volatile does not give you is atomicity:volatile int x; x++still loses updates. It also isn't a full fence: a store followed by a load of a different variable can still be reordered.Interlockedgives atomic read-modify-write plus a full fence. And this matters more on Arm64, which reorders much more than x64, so missing barriers that 'work' on a developer laptop fail on Graviton or Cobalt servers."
| Problem | Example | Effect |
|---|---|---|
| Hoisting / caching | while (!_stop) { } |
JIT reads _stop once into a register → infinite loop |
| Reordering (writer) | _data = Build(); _ready = true; |
Another core sees _ready == true before _data |
| Reordering (reader) | if (_ready) Use(_data); |
Reader's load of _data happens early → stale/null |
Writer: Reader:
_data = Build(); if (Volatile.Read(ref _ready)) // ACQUIRE
Volatile.Write(ref _ready, true); // RELEASE Use(_data); // guaranteed to see Build()'s writes
^ everything above stays above ^ everything below stays below
volatile vs Volatile vs Interlocked vs lockvolatile / Volatile.* |
Interlocked |
lock |
|
|---|---|---|---|
| Atomic read-modify-write | No | Yes (single location) | Yes (whole block) |
| Ordering | Acquire (read) / release (write) | Full fence | Acquire on enter, release on exit |
| Prevents store→load reordering | No | Yes | Yes (across enter/exit) |
| Mutual exclusion | No | No | Yes |
| Cost | Lowest | Low | Low–medium |
| Typical use | Stop flags, publishing immutable objects | Counters, CAS, once-flags | Multi-step invariants |
volatile keyword |
Volatile class |
|---|---|
| Applies to every access of the field | Explicit per access: clearer intent |
Not allowed on locals, long, double, or structs |
Works with ref to array elements, long, double |
| Easy to forget which accesses matter | Each acquire/release is visible in code review |
| Reordering | x64 | Arm64 | Fixed by volatile? |
|---|---|---|---|
| Load → load | No | Yes | Yes (acquire) |
| Store → store | No | Yes | Yes (release) |
| Load → store | No | Yes | Yes |
| Store → load | Yes | Yes | No: needs a full fence |
x64's strong ordering hides most publication bugs; Arm64 exposes them.
Our [feature-flag cache] published a new snapshot by assigning _snapshot = newSnapshot; and then _ready = true; with plain writes; readers checked _ready and then read _snapshot. It worked on x64 for years. After we moved to [Arm64 Graviton] instances, we saw rare NullReferenceExceptions when reading the snapshot. A stress test on an Arm64 runner reproduced it within minutes: readers occasionally observed _ready == true before the snapshot reference. I removed the separate flag and published the snapshot reference itself with Volatile.Write, reading it with Volatile.Read into a local. The exceptions stopped, and we added Arm64 runners to CI for all concurrency tests.
Expert check of the memory model.
Green flags
Red flags
| Operation | x64 code | Arm64 code |
|---|---|---|
Volatile.Read(ref x) |
Plain mov (x64 loads already have acquire semantics) + compiler barrier |
ldar (load-acquire) |
Volatile.Write(ref x, v) |
Plain mov (x64 stores already have release semantics) + compiler barrier |
stlr (store-release) |
Interlocked.* / MemoryBarrier() |
lock-prefixed instruction / mfence-equivalent |
dmb ish or atomic instructions with acquire-release |
On x64, volatile mostly constrains the JIT (no hoisting, no reordering). On Arm64, it also emits ordering instructions. Either way, write the code against the memory model, not the CPU you test on.
private bool _stop;
public void Run() { while (!_stop) { DoTinyWork(); } } // may be compiled as: if (!_stop) while (true) DoTinyWork();
The optimiser is allowed to assume no other thread changes _stop unless told otherwise. volatile bool _stop or Volatile.Read(ref _stop) forbids that hoisting. In real code, a CancellationToken is clearer.
// Thread A // Thread B
Volatile.Write(ref _a, 1); Volatile.Write(ref _b, 1);
int rb = Volatile.Read(ref _b); int ra = Volatile.Read(ref _a);
// Both ra and rb can be 0! Each core's store sits in its store buffer while its load executes.
Fix: Interlocked.Exchange for the writes, or Interlocked.MemoryBarrier() between write and read, or a lock.
private Config? _config;
public void Publish(Config c) => Volatile.Write(ref _config, c); // release: c's fields are visible first
public Config? Current => Volatile.Read(ref _config); // acquire
This pattern needs no flag. The object must be immutable after publication; if readers might mutate it, you need more.
The runtime team documents the .NET memory model (in the dotnet/runtime repository) to specify guarantees across platforms: aligned reads and writes up to pointer size are atomic, object reference writes aren't torn, volatile operations have acquire/release semantics, and Interlocked operations are full fences. Some additional guarantees (such as around publishing freshly constructed objects) exist, but code that depends on subtle implicit guarantees is fragile. Prefer explicit Volatile, Interlocked, Lazy<T> or locks.
| Version | Change |
|---|---|
| .NET Framework 4.5 | System.Threading.Volatile class |
| .NET Core 3.0+ | Arm64 support (memory model differences now matter for .NET servers) |
| .NET 5+ | Arm64 performance work; LSE atomics where available |
| .NET 7/8 era | Memory model specification published in the runtime repo |
| .NET 10 | Same semantics |
while (!_stop) loop without volatile in a Release build, run it, and see whether it ever stops (JIT-dependent); then add Volatile.Read.Volatile and count how often both read 0 over millions of runs; then add Interlocked.MemoryBarrier().Volatile.Read and Interlocked.Increment in SharpLab's JIT Asm view for x64 and Arm64.using System.Threading;
var publisher = new Publisher();
var reader = Task.Run(() =>
{
while (!publisher.TryRead(out var config)) Thread.SpinWait(10);
Console.WriteLine($"Read config: {publisher.Current!.Name}");
});
Thread.Sleep(50);
publisher.Publish(new Config("prod"));
await reader;
var worker = new StoppableWorker();
var loop = Task.Run(worker.Run);
Thread.Sleep(100);
worker.Stop();
await loop;
Console.WriteLine("Worker stopped");
public sealed record Config(string Name);
public sealed class Publisher
{
private Config? _current;
private bool _ready;
public Config? Current => _current;
public void Publish(Config c)
{
_current = c; // 1. write data
Volatile.Write(ref _ready, true); // 2. release: data visible before flag
}
public bool TryRead(out Config? c)
{
if (Volatile.Read(ref _ready)) // acquire: later read sees data
{
c = _current;
return true;
}
c = null;
return false;
}
}
public sealed class StoppableWorker
{
private volatile bool _stop; // every access is volatile
public void Stop() => _stop = true;
public void Run() { while (!_stop) Thread.SpinWait(100); }
}
volatile makes ++ atomicWhat happens: volatile int _count; _count++;.
Why it is wrong: read and write are each ordered, but another thread can interleave between them.
Do instead: Interlocked.Increment or a lock.
volatile for mutual exclusion (Dekker/Peterson)What happens: two volatile flags used to keep threads out of a critical section.
Why it is wrong: volatile doesn't prevent store→load reordering.
Do instead: a lock, or Interlocked operations (full fences).
What happens: tests pass on laptops and x64 CI. Why it is wrong: x64's strong ordering hides bugs that Arm64 exposes. Do instead: run concurrency tests on Arm64 too.
volatile to fields already protected by a lockWhat happens: fields read/written only inside lock are also marked volatile.
Why it is wrong: redundant cost and confusing intent.
Do instead: one consistent strategy per field.
What happens: reader checks the flag with Volatile.Read but read the data before checking.
Why it is wrong: acquire only orders operations after the volatile read.
Do instead: read the flag (or reference) first, then the data.
What happens: a config object is published, then its properties updated. Why it is wrong: readers may see partially updated state; no ordering for later writes. Do instead: publish immutable objects; replace rather than mutate.
volatile instead of CancellationTokenWhat happens: custom stop flags everywhere.
Why it is wrong: works, but doesn't integrate with async APIs, timeouts or linked cancellation.
Do instead: CancellationToken for cooperative cancellation.
What happens: volatile is explained as bypassing caches. Why it is wrong: CPU caches are coherent; volatile is about ordering and preventing compiler optimisations. Do instead: explain acquire/release semantics.
What happens: Volatile.Read(ref _cfg).A then Volatile.Read(ref _cfg).B.
Why it is wrong: the two reads may see different objects.
Do instead: read once into a local.
What happens: if (_x == null) lock (...) if (_x == null) _x = new X(); with a plain field.
Why it is wrong: fragile under weaker memory models; easy to get subtly wrong.
Do instead: Lazy<T> or LazyInitializer.EnsureInitialized.
I treat volatile as a precise tool for publication and stop flags, and I avoid building anything bigger on it. When reviewing lock-free code I ask which accesses are acquire, which are release and where a full fence is needed, and I make sure we test on Arm64.
| Pattern | Primitive |
|---|---|
| Stop flag | volatile bool or CancellationToken |
| Publish an immutable object | Volatile.Write of the reference |
| Counter | Interlocked |
| Lazy initialisation | Lazy<T> or LazyInitializer |
| Anything multi-step | lock |
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.
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.
How to tell it (STAR)
After we moved to [Arm64 instances], our [flag cache] threw rare null references. The code set the data and then a flag with plain writes, which happened to work on x64. I showed it was a reordering issue, published the reference with `Volatile.Write`, and added Arm64 runners to CI.
Last updated 31 Oct 2026.