Mert Özen Further With Every Line
Computer Science Fundamentals Concurrency Theory — #06

The .NET Memory Model: Happens-Before, Acquire/Release, volatile and Safe Object Publication

Mert Özen Nov 14, 2026 11 min 14 views
The .NET Memory Model: Happens-Before, Acquire/Release, volatile and Safe Object Publication

In the .NET memory model ordinary accesses may be reordered; Volatile.Read is an acquire, Volatile.Write a release, and Interlocked and lock are the points that guarantee order. We show a non-volatile stop flag loop never stopping in a Release build because of the JIT, double-checked locking with a bool flag returning null 36 times in 20 million calls on ARM64, derive from the spec why checking the reference is correct, and compare with Java, C++ and Go.

Version note: The code in this post was built and run with .NET 10 (C# 14); tests use xUnit v3 4.0.3. The rules are taken from the .NET memory model spec in the dotnet/runtime repository. The outputs were taken on ARM64; statements about x64 are based on that spec and Intel's documentation.

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, the shipping worker runs in a loop and checks a bool _stop field on every turn. When the application shuts down, the main thread writes _stop = true. The field is an ordinary field; no volatile, no lock. In a Release build, how long does the worker take to stop after the main thread raises the flag: a millisecond, a second, never?

Second question: a cache loads the price list the first time it is needed. It is written with double-checked locking: it first checks a lock-free bool _loaded flag, and if the list is not loaded it takes the lock, checks again, loads the list and raises the flag. Can this Get() call ever return null? In the previous post we saw the hardware run the same code in different orders on x64 and ARM64. In this post we look at the contract .NET puts on top of all that hardware: the .NET memory model.

Why a Language Memory Model Exists

A programming language is not designed for a single CPU. C# code can run on x64, ARM64, x86 and ARM32; in between sits a JIT that transforms the code. A language memory model is the contract between these three parties: it tells the programmer which orders are guaranteed, tells the JIT which optimizations are allowed, and tells the hardware what the runtime expects from it.

The contract has two extremes. A very strong model (run everything with sequential consistency) would require a barrier on almost every memory access on ARM64 and slow programs down. A very weak model makes it impossible for programmers to write correct code. The common ground Java, C++ and Go reached is the idea of DRF-SC, short for "data-race-free programs are sequentially consistent": if your program has no data races, you do not have to think about ordering. If it has races, the language either limits what can happen with its own rules or does not limit it at all.

.NET's story is a little different. ECMA-335, the standard definition of the CLI, defined a very weak model. The memory model document in the dotnet/runtime repository says that over the years the guarantees the runtime actually gives became stronger than that standard, and that the document writes down today's implementation and the behaviour expected to be kept in future. The rules in this post come from that document, in our own words.

Happens-Before and Acquire/Release

The basic concept used when talking about memory models is happens-before. In the logical clocks post of the Distributed Systems Fundamentals series we saw the same relation Lamport defined for distributed systems in 1978: if A happened before B and can affect B, then A happens-before B. Inside a single thread, program order creates this relation. Between two threads it can only be created by a synchronization point: a release write by one thread pairs with an acquire read by the other thread on the same variable that sees the value of that write. Everything done before the release is visible to everything done after the acquire.

The message passing test in the previous post is the simplest example: the total was written, the flag was raised with Volatile.Write (release), the reading side saw the flag with Volatile.Read (acquire) and read the total. With plain access, flag 1 and total 0 was seen in 6,291,785 of 10 million rounds on ARM64; with release/acquire it was never seen.

Unlike Java and C++, the .NET spec does not define happens-before as a formal relation; it describes the rules through acquire, release and full-fence semantics. As a thinking tool it is the same thing, though: you answer "must this read see that write?" by checking whether there is a release-acquire chain between them. Since leaving a lock is a release and entering the same lock is an acquire, code protected by a lock gets this chain automatically. Thread.Start, Join and waiting for a Task to complete are also points that create this chain in practice.

The Rules of the .NET Spec

Grouping the rules of the spec gives this list:

  • Ordinary reads and writes may be reordered by both the JIT and the hardware, as long as the result seen by a single thread stays the same.
  • This freedom has limits: no speculative writes, and no reads that are not in the program may be introduced; but unused reads may be removed, and adjacent non-volatile reads (or writes) of the same address may be merged into one operation.
  • A volatile read is an acquire, a volatile write is a release. Volatile semantics do not make an operation atomic and say nothing about how quickly a write becomes visible; they only say in which order things become visible when they do.
  • Thread.MemoryBarrier and every Interlocked method are full fences. Taking a lock counts as an acquire, releasing it as a release.
  • Assigning an object reference to a shared location is a release with respect to that object's fields, and reads made through the reference (data-dependent reads) cannot happen before the reference itself is read. Instance constructors have no ordering effect of their own.
  • Reads and writes of properly aligned primitive types up to pointer size, and of references, are atomic. The Volatile and Interlocked methods are atomic regardless of the platform.

We saw what these rules turn into on ARM64 in the JIT output of the previous two posts:

.NET operationMeaning in the specWhat the JIT emits on ARM64 (measured)
Plain read/writeNo ordering guaranteeldr / str
Volatile.ReadAcquireldapr / ldapur
Volatile.WriteReleasestlr / stlur
Interlocked.MemoryBarrierFull fencedmb ish

On x64 the table is simpler: TSO already gives acquire and release at no extra cost, so volatile reads and writes are ordinary mov instructions; all the JIT does is not reorder these accesses itself. That is why people say "volatile is free on x64", and for the same reason a forgotten Volatile usually goes unnoticed on x64.

Case 1: The Loop That Never Ends

This is the code of the first opening question:

public void RunPlain()
{
    long spins = 0;
    while (!_stop)
        spins++;
    Spins = spins;
}

public void RunVolatileRead()
{
    long spins = 0;
    while (!Volatile.Read(ref _stop))
        spins++;
    Spins = spins;
}

The experiment starts the worker, raises the flag 200 ms later and waits at most 3 seconds for the worker to finish. The third line is the same loop with the field declared with C#'s volatile keyword:

dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- stop-flag
plain field     still running after 3 s
Volatile.Read   stopped after 202 ms, 876,317,428 spins
volatile field  stopped after 203 ms, 874,082,497 spins

The loop written with a plain field never stopped. The JIT output shows why. The loop started in Tier0, the quickly compiled, unoptimized code, and Tier0 code read the field from memory on every turn. As the loop kept running, the runtime switched to the optimized version through on-stack replacement (OSR), and that version looks like this:

; PollingWorker:RunPlain():this (Tier1-OSR)
ldrb    w2, [x0, #0x10]
cbnz    w2, G_M000_IG04
G_M000_IG03:
b       G_M000_IG03

The field is read once; if it is 0, the code spins forever on a b instruction that jumps to itself. The spec explicitly allows this optimization: adjacent non-volatile reads of the same address may be merged, and billions of reads inside the loop may be reduced to a single read before the loop. This is not a hardware matter; the same code hangs the same way on x64. Volatile.Read or a volatile field makes the reads impossible to merge, and the loop stops 2-3 ms after the flag is raised. The spec also notes that C#'s volatile keyword has no separate meaning for the runtime; it only makes the compiler emit accesses to that field with the volatile. prefix in IL.

The nasty part of this bug is that it does not show in a Debug build. In Debug the JIT does not optimize, the field is read on every turn and the loop stops. The code hangs once it moves to Release or when the tiered compilation settings change.

Case 2: Double-Checked Locking

The second question is the badly written version of a classic lazy initialization pattern:

// Broken: a separate bool says "loaded"; nothing orders the flag against the reference
public PriceList? Get()
{
    if (!_loaded)
    {
        lock (_gate)
        {
            if (!_loaded)
            {
                _list = new PriceList(29.90m);
                _loaded = true;
            }
        }
    }
    return _list;
}

The two writes inside the lock are not ordered relative to each other, and neither are the two reads of a reader that never takes the lock. This is exactly the message passing test of the previous post: the reader can see the flag as 1 and the reference as null. In the experiment two threads call Get() on a freshly created cache at the same time; I added a random SpinWait between 0 and 20 to the second thread so that it arrives at different moments while the first thread is initializing. I compared it with two correctly written versions:

// Correct under the .NET memory model: the reference itself is the check, read once into a local
public PriceList? Get()
{
    PriceList? list = _list;
    if (list is null)
    {
        lock (_gate)
        {
            list = _list;
            if (list is null)
            {
                list = new PriceList(29.90m);
                _list = list;
            }
        }
    }
    return list;
}
dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- double-check 10000000
bool flag        calls=20,000,000 null or empty price list=36
reference check  calls=20,000,000 null or empty price list=0
Lazy<T>          calls=20,000,000 null or empty price list=0

The flag version returned null or an empty price list in 36 of 20 million calls. In a second run the number was 2. Compared to the millions of the message passing test it is a small number, because the window for the bug is only the few nanoseconds of the first load. In a real application that means a NullReferenceException that shows up once a month and that nobody can reproduce.

Why is the version that checks the reference correct? Because of two rules in the spec: writing an object reference to a shared field is a release with respect to the fields the constructor wrote, and on the reading side, reads made through the reference cannot happen before the reference is read. If the reader sees the reference as non-null, it sees the whole object. I measured this with a separate experiment too: one thread writes a new PriceList to a plain field, the other reads the field and checks the fee.

dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- publish 10000000
not yet=5,982,090 complete object=4,017,910 default fee=0

In none of the 4 million rounds in which the reader saw the object was the fee at its default value. Java did not have this guarantee before JSR-133 in 2004, the specification that redefined the Java memory model, and that is where double-checked locking's reputation as "broken" comes from. In .NET the correctly written version works.

Still, my advice is not to write your own double-checked locking. Lazy<T> in its default mode (ExecutionAndPublication) does the same job, guarantees that the factory runs only once, and was error-free in the experiment. In the Singleton post of the Design Patterns series we compared Lazy<T> and the DI singleton lifetime from this angle. The "reference check" rule of the spec is what you need to judge whether a double-check in code you are reading is correct; it is not for new code.

The Same Concepts in Other Languages

The same concepts appear in every language under different names and with different defaults:

LanguageMeaning of a data raceDefault ordering of "volatile" or atomics
C# / .NETDefined but weak: a read sees a written value, order is freeVolatile: acquire/release (does not prevent SB)
Java (JLS 17)Defined; race-free programs are sequentially consistentvolatile: sequentially consistent (prevents SB)
C++11Undefined behaviorstd::atomic defaults to memory_order_seq_cst; acquire, release, relaxed can be chosen
Go (2022 model)Defined but limited; multiword values can corrupt memorysync/atomic: sequentially consistent

The most commonly confused point is volatile in Java versus C#. According to chapter 17 of the Java Language Specification, volatile reads and writes enter a single synchronization order that all threads agree on; that is why the store buffering outcome of the previous post is impossible in Java with two volatile fields. C#'s Volatile only gives acquire/release, and the same test made both threads read 0 in 11,272 of 10 million rounds on ARM64. If someone coming from Java writes a Dekker or Peterson style protocol with volatile in C#, the code is broken.

C++11 sits at the other end: two threads accessing the same location without synchronization is undefined behavior, and the compiler does not have to give the program any meaning in that case. As we saw in the second post, .NET is milder. Go's memory model document, revised in 2022, also builds on DRF-SC and limits the outcomes of racy programs, but admits that a race on values made of more than one word (slices, interfaces, strings) can lead to memory corruption. The same document advises the reader not to be clever; a program whose behaviour you can only understand by reading the memory model is written too cleverly.

In Production

Set yourself a hierarchy. For shared state, think first of not sharing, then of immutable data, then of lock, then of Interlocked. Use Volatile only for simple cases such as a single flag or publishing a single reference; when you see Thread.MemoryBarrier in code, look for a comment that explains why. Code written with locks and Interlocked does not require you to think about any rule in this post; the tools we covered in the synchronization post of the C# Asynchronous Programming series exist exactly for that.

In code review, look for fields that are read without a lock. If a field is written on one thread and read on another without a lock, ask two questions: is the read inside a loop (the risk of merged reads), and does the value only make sense together with another field (the risk of reordering)? If either answer is yes, a plain field is not enough.

Run your tests on ARM64 too. The bug in the flag-based double-check never shows on x64 hardware; even on ARM64 it shows a few times in 20 million calls. Show the correctness of this kind of code by reasoning from the spec, not by testing.

Common Pitfalls

Taking volatile for atomicity. On a volatile int _sold field, _sold++ is still made of read, increment and write steps:

dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- volatile-count
expected=8,000,000 sold=2,315,624

Of the 8 million sales made by eight threads, 2,315,624 remained. Volatile keeps order; it does not stop another thread from stepping in between. For an atomic increment use Interlocked.Increment.

Mixing up ECMA-335 with today's CLR behaviour. Older sources describe ECMA's weak model and say publishing a reference is unsafe; today's runtime gives the guarantees above. The opposite is also a trap: relying on something the spec does not guarantee, such as today's x64 JIT not reordering plain accesses.

Leaking this from a constructor. According to the spec constructors have no ordering effect of their own; the guarantee only holds when the reference is written to a shared field after the constructor finishes. If you start a thread in the middle of a constructor and hand it this, as in the shipment tracking example of the second post, that thread can see a half-built object.

Trusting a loop that works in Debug. The stop flag bug does not show in a Debug build; it shows only in optimized code, and only after tiered compilation recompiles the loop.

A Small Experiment

★☆☆ Run the stop-flag command in a Debug build (-c Debug). Did the plain-field loop stop? Then try Release with the DOTNET_TieredCompilation=0 environment variable and explain the difference.

★★☆ Fix FlagCheckedCache by adding only Volatile.Write and Volatile.Read on the _loaded field. Check with the double-check command that the error count drops to zero, and explain through release/acquire why making only the flag volatile is enough.

★★★ Measure the difference between Java's volatile and C#'s Volatile: write a version of the previous post's store buffering test that does its reads and writes with Interlocked.Exchange and Interlocked.CompareExchange(ref x, 0, 0). Why did the result change? Compare the cost of this version with the Volatile version.

In this post we derived from the memory model why locks and Interlocked work correctly. But can we write a correct data structure without any lock, using only compare-and-swap? In the next post of the series we move to lock-free and wait-free algorithms: the CAS loop, the Treiber stack, the ABA problem and linearizability as the correctness criterion.