AlgoInsight

Free lesson · Concurrency Interview Mastery

The C# Memory Model

Medium10 min readconcurrencymultithreadingcsharp

The memory-model story you learned in Module 1 and saw in Java and C++ applies to C# too: without synchronization, writes by one thread may not be visible to another, and the compiler, JIT, and CPU may reorder operations. C# and .NET give you their own tools to establish the ordering and visibility guarantees you need - volatile, Volatile.Read/Write, Thread.MemoryBarrier, the lock statement, and the Interlocked class for atomic operations. This lesson is the C#-specific version of the memory-model toolkit, plus one wrinkle that trips people up: the .NET memory model has historically been stronger in practice than its specification guarantees, which is a trap for portable code.

The same underlying problem

To recap quickly in C# terms: each CPU core has caches, so a write by one thread can sit in a local cache invisible to another thread. And the C# compiler, the JIT compiler, and the processor may all reorder instructions for performance, preserving only single-threaded observable behavior. So a flag written by one thread to signal another can be missed, and operations can appear to happen out of order across threads. Everything from Module 1's memory-model lesson holds; only the fixing tools differ.

bool ready = false;     // plain field - NOT safe for cross-thread signaling
int data = 0;

// Thread A:
data = 42;
ready = true;           // intended signal

// Thread B:
while (!ready) { }      // may loop forever (visibility) or see ready before data (reordering)
use(data);

This is the same broken signaling pattern from every language. Without proper synchronization, thread B may never see ready become true, or may see ready true before data's write is visible. The fixes follow.

volatile and Volatile.Read/Write

The volatile keyword in C# marks a field so that reads and writes to it have acquire/release semantics - a volatile read won't be reordered with later operations, and a volatile write won't be reordered with earlier ones, and the value isn't cached in a way that hides updates. This makes it usable for the flag pattern above.

volatile bool ready = false;   // now safe as a signal flag

There's also the Volatile class - Volatile.Read(ref field) and Volatile.Write(ref field, value) - which applies the same semantics at a specific access point rather than to the whole field. This is useful when most accesses to a field are ordinary but a few specific ones need volatile semantics.

Important: As in every language, volatile gives you visibility and ordering, not atomicity of compound operations. A volatile int counter; counter++; is still a read-modify-write race - volatile makes individual reads and writes ordered and visible, but counter++ is multiple operations that can interleave. For atomic compound operations you need Interlocked or a lock, not volatile. Reaching for volatile on a counter is the same classic mistake here as everywhere, and a reliable interview trap.

Thread.MemoryBarrier

For explicit control, Thread.MemoryBarrier() inserts a full memory barrier - it prevents reordering of memory operations across that point and forces caches to reconcile. You rarely need it directly (the higher-level tools emit barriers for you), but it's the low-level primitive underneath, and knowing it exists explains how the higher-level constructs achieve their guarantees.

flowchart TD
    A[Thread A writes data] --> B[Thread A writes flag]
    B -.without barrier/volatile.-> R1[reorder/cache: B may see flag before data, or never]
    A2[Thread A writes data] --> MB[volatile/barrier: publishes writes, forbids reorder]
    MB --> B2[Thread B sees data correctly]
This diagram contrasts unsynchronized and synchronized signaling in C#. On the top path, thread A writes some data and then writes a flag, but without a barrier or volatile, reordering and caching mean thread B might see the flag before the data, or might never see the flag at all. On the bottom path, thread A writes the data, then a volatile write or memory barrier publishes those writes and forbids reordering, so thread B correctly sees the data. The barrier or volatile is what converts unreliable cross-thread signaling into reliable signaling.

locks establish ordering too

As in Java and C++, the lock statement (covered fully in the next lesson) does double duty: mutual exclusion and memory ordering. Everything a thread does before releasing a lock is visible to the next thread that acquires the same lock, and the lock boundaries prevent reordering across them. So data protected by a lock doesn't separately need volatile - the lock already provides the visibility. This is the most common and most robust way to get both mutual exclusion and correct memory ordering in C#, and for most shared mutable state, a lock is the right tool rather than fiddling with volatile and barriers directly.

Interlocked: atomic operations

For lock-free atomic operations on a single variable, C# provides the Interlocked class - the .NET equivalent of Java's atomics or C++'s std::atomic. These perform read-modify-write atomically, without a lock, using hardware support.

int counter = 0;

Interlocked.Increment(ref counter);          // atomic ++
Interlocked.Decrement(ref counter);          // atomic --
Interlocked.Add(ref counter, 5);             // atomic += 5
int old = Interlocked.Exchange(ref counter, 10);   // atomic set, returns old

// Atomic compare-and-swap: if counter==expected, set to newValue
Interlocked.CompareExchange(ref counter, newValue, expected);

Interlocked.CompareExchange is the C# compare-and-swap (CAS) - the foundation of lock-free programming, returning the original value so you can build retry loops. This is the right tool for a shared counter or a single atomic flag where a lock would be overkill: Interlocked.Increment on a counter is both correct and faster than locking.

Tip: The decision mirrors every other language: use Interlocked for atomic operations on a single variable (counters, flags, single CAS-based updates); use a lock when you need to protect a group of operations or a multi-step invariant. volatile is for visibility/ordering of a flag that one thread writes and others read, not for compound updates. Matching the tool to the granularity - single variable versus critical section - is the core judgment, and "I'd use Interlocked.Increment for the counter, not a lock" is a sign you know the .NET toolkit.

The strength-stronger-than-spec wrinkle

One C#-specific subtlety worth knowing for depth. The ECMA specification for the .NET memory model defines certain minimum guarantees, but the actual implementation - particularly on the strongly-ordered x86/x64 architectures most .NET historically ran on - has often provided stronger guarantees than the spec requires. This means code with a subtle memory-model bug can appear to work correctly on x86, because the hardware's strong ordering accidentally papers over the missing synchronization, then fail on a weakly-ordered architecture like ARM.

Warning: Don't rely on the implementation being stronger than the specification. Code that "works" because x86's strong memory ordering happened to save you can break when run on ARM (increasingly common with ARM servers and Apple Silicon) or under a different runtime. Write to the specified memory model - use volatile, Interlocked, lock, or Volatile.Read/Write to establish the ordering you need explicitly - rather than depending on observed behavior on one architecture. This is the same lesson as C++'s "works on x86, breaks on ARM," and it's increasingly relevant as ARM hardware spreads. Correct synchronization is portable; lucky hardware behavior is not.

Why the memory model is the source of invisible bugs

The reason the memory model deserves careful attention, rather than being dismissed as theory, is that its bugs are among the most invisible and expensive in all of software. A memory-model bug - a missing volatile, an unsynchronized shared field - typically does not announce itself. The code compiles cleanly, passes code review (because it looks correct line by line), and works in testing, often for a long time. It only manifests under specific timing, on specific hardware, under specific load - and then it produces a symptom, like a flag that's ignored or a value that's stale, that seems impossible given the source code.

This invisibility is precisely why you cannot rely on testing to catch memory-model bugs and must instead reason about them and prevent them by construction. A test that runs the code a million times on your development machine may hit the bad interleaving or the cache-visibility failure zero times, because your machine's timing and memory architecture happen never to expose it. The bug is fully present in the code the entire time; the test simply never triggers the condition that reveals it. Then it reaches production, runs on different hardware under real concurrency, and surfaces. The defense is the same discipline as in every language: identify every field shared across threads and ensure each is accessed under proper synchronization - a lock, Interlocked, or volatile/Volatile as appropriate - rather than trusting that small or simple-looking operations are safe. Tools that detect data races and concurrency issues are worth running because the language gives you limited protection against the bugs you miss.

A practical decision guide

To make the C# toolkit actionable, here's how to choose among the options for a given shared-state situation, because having three or four tools without a clear decision rule is its own source of mistakes.

If a single field is written by one thread and read by others as a signal - a flag indicating readiness, a status that one thread sets and others observe - volatile (or Volatile.Read/Write) is the lightweight correct choice. It gives the visibility and ordering you need for a flag without the overhead of a lock. This is the narrow, proper use of volatile.

If multiple threads need to update the same single variable - a counter being incremented, a value being compared-and-swapped - Interlocked is the tool. It performs the read-modify-write atomically and lock-free, which is both correct (unlike volatile, which doesn't make ++ atomic) and faster than taking a lock for a single-variable update. Interlocked.Increment for a shared counter is the canonical example.

If you need to protect a group of operations together, maintain an invariant across multiple fields, or perform any multi-step update that must appear atomic as a unit, use a lock. The lock gives you both mutual exclusion over the whole critical section and the memory ordering that publishes all your writes to the next thread. This is the general-purpose tool, and when in doubt - especially when more than one variable is involved or the logic is more than a single operation - a lock is the safe default. The mistake to avoid in both directions is using volatile where you need atomicity (it won't give it) and using a heavyweight lock where a single Interlocked operation would do (it's unnecessary overhead). Match the tool to the shape of the problem: flag visibility, single-variable atomic update, or multi-operation critical section.

Comparing C#'s memory model to Java and C++

Placing the three memory models side by side clarifies what's universal and what's language-specific, and it's the kind of synthesis that demonstrates real depth. All three share the same underlying reality from Module 1: caches make writes potentially invisible across threads, and compilers and CPUs reorder operations, so cross-thread communication needs explicit synchronization to establish happens-before ordering. That core problem is identical everywhere; it comes from hardware, not from any language.

Where they differ is in the consequences of getting it wrong and in the precise guarantees. C++ is the harshest: a data race is undefined behavior, meaning the program has no defined semantics at all and anything can happen. Java is more forgiving: an unsynchronized access yields a wrong-but-bounded result - a stale value or lost update - while the program stays in a defined state, and Java's memory model is carefully specified with strong guarantees for volatile and final. C# sits close to Java in spirit - volatile, locks, and Interlocked provide visibility and ordering, and an unsynchronized access gives an incorrect but not catastrophic result rather than C++-style undefined behavior - but with the wrinkle that .NET's actual implementation has historically been stronger than its specification on x86, which creates the portability trap covered above.

The tooling vocabulary maps across cleanly: C#'s volatile corresponds to Java's volatile and C++'s acquire/release atomics; C#'s Interlocked corresponds to Java's atomic classes and C++'s std::atomic with read-modify-write operations; C#'s lock corresponds to Java's synchronized and C++'s mutex-with-RAII, all providing both mutual exclusion and memory ordering. Knowing that these are the same concepts wearing different names lets you carry your understanding across languages instead of relearning it each time. The practical universal rule, true in all three: every shared mutable access needs real synchronization, "being careful" is never sufficient, and the bugs you skip are invisible in testing and surface in production on different hardware. C# expresses that rule with lock, Interlocked, and volatile; the rule itself is the same one you'll apply for the rest of your career in any language.

Note: If you take one habit from this lesson, make it this: treat every field touched by more than one thread as needing an explicit decision about how it's synchronized - lock, Interlocked, or volatile - rather than leaving it to chance. The cost of that habit is a moment's thought per shared field; the cost of skipping it is a class of bug that's invisible in review and testing and surfaces unpredictably in production. In concurrent code, explicit synchronization isn't defensive over-engineering - it's the baseline for correctness.

The interview angle

Interview: Common questions: (1) "What does volatile do in C#?" - Gives a field acquire/release semantics: visibility and ordering for individual reads/writes, preventing caching and certain reorderings - but not atomicity of compound operations like counter++. (2) "How do you do atomic operations?" - The Interlocked class: Increment, Add, Exchange, and CompareExchange (CAS), all lock-free. (3) "How do you safely share a counter across threads?" - Interlocked.Increment, not volatile (which doesn't make ++ atomic) and not necessarily a lock (overkill for one variable). (4) "Does lock affect memory visibility?" - Yes; like in Java, a lock provides both mutual exclusion and memory ordering, so locked data is published to the next acquirer. Catching that volatile ≠ atomic and that Interlocked is the counter tool signals real understanding.

Tip: When you're unsure which synchronization tool a piece of shared state needs, default to a lock. It's the most general tool - it handles single variables and multi-step invariants alike, it provides both mutual exclusion and memory ordering, and it's the hardest to get subtly wrong. You can always optimize a hot, single-variable case down to Interlocked or a flag down to volatile later, with measurement to justify it. Starting from the safe, general default and specializing only when proven necessary is the right order of operations, and it keeps you out of the memory-model traps that catch people who reach for the lightweight tools first.

Quick reference

using System.Threading;

volatile bool flag;                    // visibility + ordering (NOT atomic compound ops)
Volatile.Read(ref field);              // volatile semantics at a point
Volatile.Write(ref field, value);
Thread.MemoryBarrier();                // explicit full barrier (rarely needed directly)

// atomic single-variable ops (lock-free)
Interlocked.Increment(ref n);
Interlocked.Add(ref n, k);
Interlocked.Exchange(ref n, v);
Interlocked.CompareExchange(ref n, newVal, expected);   // CAS

lock (obj) { ... }                     // mutual exclusion + memory ordering

// RULES: volatile = flag visibility; Interlocked = single-var atomics;
//        lock = critical sections; write to the SPEC, not to x86's lucky behavior

Next, the everyday synchronization tools in detail: the lock statement, the Monitor class it's built on, and the broader landscape of C# synchronization primitives.

Back to Concurrency Interview Mastery