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

📚 Interview question

What does volatile mean in C#, and how do Volatile.Read and Volatile.Write differ from Interlocked?

Compiler and CPU reordering, acquire and release semantics, the volatile keyword, Volatile class, x64 versus Arm64 memory models and what volatile does not guarantee.

Senior Theory Expert arm64 concurrency memory-model volatile

🎯 Key points

  • volatile prevents unsafe caching and reordering of a field access
  • Volatile.Read has acquire semantics; Volatile.Write has release semantics
  • Enables publish-then-flag patterns across threads
  • Does not make read-modify-write atomic
  • Does not prevent store-load reordering; needs a full fence
  • Interlocked gives atomicity plus a full fence
  • Arm64 reorders more than x64, so missing barriers show up there

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

"volatile, or the explicit Volatile.Read and Volatile.Write methods, 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. Interlocked gives 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."


1. What can go wrong without it

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

2. Acquire and release

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

3. volatile vs Volatile vs Interlocked vs lock

volatile / 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

4. The keyword vs the class

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

5. x64 vs Arm64

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.

Production example

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.

🔍 What the interviewer is checking

Expert check of the memory model.

Green flags

  • Explains register caching and reordering.
  • Uses acquire and release terms correctly.
  • States that volatile is not atomic and not a full fence.
  • Mentions x64 versus Arm64 differences.

Red flags

  • "volatile means always read from main memory" with no ordering explanation.
  • Uses volatile for counters.

📚 Learn in depth

1. What the hardware actually does

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.

2. The JIT hoisting case

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.

3. Store → load reordering (why volatile isn't enough for Dekker)

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

4. Publishing immutable objects

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.

5. The .NET memory model

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.

6. Version notes

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

7. Try it yourself

  1. Run the code example.
  2. Write the while (!_stop) loop without volatile in a Release build, run it, and see whether it ever stops (JIT-dependent); then add Volatile.Read.
  3. Implement the Dekker test (two threads, two flags) with Volatile and count how often both read 0 over millions of runs; then add Interlocked.MemoryBarrier().
  4. Run a publication stress test with plain writes on an Arm64 machine (Apple Silicon or a cloud Arm VM) and compare with x64.
  5. View Volatile.Read and Interlocked.Increment in SharpLab's JIT Asm view for x64 and Arm64.

💻 Code

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

⚠ Common mistakes

1. Believing volatile makes ++ atomic

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

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

3. Testing lock-free code only on x64

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.

4. Adding volatile to fields already protected by a lock

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

5. Volatile flag but plain data reads on the reader side

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.

6. Mutating an object after publishing it

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.

7. Using volatile instead of CancellationToken

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

8. Expecting "read from main memory"

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.

9. Reading a volatile reference multiple times in one operation

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.

10. Hand-rolling double-checked locking without volatile

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says volatile is not atomic Why: Checks Interlocked usage
    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.

  2. When: Candidate mentions full fences Why: Probes memory barriers
    How to 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.

🛠 Real-world problems: how you handle them

Publication bug that appeared only on Arm64
Situation
A [feature-flag cache] published a new snapshot by setting a field and then a `_ready` flag without volatile.
Signals
After moving to [Arm64 Graviton] instances, rare `NullReferenceException`s appeared when reading the snapshot.
Investigation
Code review found plain writes; on x64 they had always appeared in order. A stress test on Arm64 reproduced it.
Resolution
Published the snapshot reference with `Volatile.Write` and read it with `Volatile.Read`, removing the separate flag.
Prevention
Added Arm64 runners to CI for concurrency tests.

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.

↩ Usually asked after

More in Synchronization

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me