Symptom
You write the initialization pattern everyone writes:
// Thread 1 // Thread 2
config = build_config(); if (ready)
ready = true; use(config);
It works for a year. Then it crashes on a new ARM server, in production, with
config null inside use. Not sometimes-null — null after ready was
observed true, which the source code says is impossible.
The volatile you add makes it go away, or seems to. On a different machine it
comes back. You start to suspect the compiler, then the CPU, then your own
sanity, and you have no vocabulary for what is happening because your mental
model of memory — one array of bytes that everyone reads and writes — is not what
the machine implements and has not been for thirty years.
Statement
Sequential consistency, Lamport’s definition: the result of any execution is the same as if the operations of all processors were executed in some total order, and the operations of each individual processor appear in this sequence in the order specified by its program.
That is the model you assumed. Your hardware does not provide it.
No mainstream architecture is sequentially consistent, and no mainstream compiler preserves it. x86 provides TSO, which allows a store to be buffered past a subsequent load. ARM and POWER are far weaker and allow most reorderings. Compilers reorder, hoist, sink, and eliminate memory operations as if the program were single-threaded.
The DRF-SC guarantee. A language memory model instead promises: if your program has no data races under every sequentially consistent execution, then every execution of it is sequentially consistent. Race-free programs get the model you assumed. Racy programs get nothing.
The bargain is explicit. You supply the absence of races, using synchronization the language recognizes, and the language supplies the illusion of a single memory. Break your side and C++ gives undefined behaviour, while Java gives a weaker but still bounded guarantee, since it must remain memory-safe for untrusted code.
Argument
The store buffer, and the litmus test that exposes it. A CPU that stalled on every store would waste enormous time, so stores go into a buffer and drain asynchronously. Loads check the buffer for the same address, so single-threaded code is unaffected. Now run:
// initially x = y = 0
// Thread 1 // Thread 2
x = 1; y = 1;
r1 = y; r2 = x;
Enumerating all interleavings of these four operations under sequential
consistency gives exactly three outcomes: $(r_1,r_2) \in {(0,1),(1,0),(1,1)}$.
The outcome $(0,0)$ is not reachable, and this is easy to see informally: for
$r_1$ to be 0, thread 1’s read of y precedes thread 2’s write, so thread 1’s
write of x also precedes it, so $r_2$ must be 1.
On x86 you observe $(0,0)$. Both stores sit in their store buffers while both loads read from a memory that has not yet seen either. This is not a bug, it is documented TSO behaviour, and it is exactly the mechanism that broke Peterson’s algorithm in T052: Peterson’s proof depends on a thread’s flag being visible before it reads the other’s, which is the same shape as this test.
The compiler is at least as aggressive as the hardware. Given
while (!ready) { }
with ready a plain variable, a compiler may observe that the loop body does not
modify ready, hoist the load out, and produce if (!ready) for(;;);. That is a
legal single-thread transformation and an infinite loop in a concurrent program.
Compilers also invent writes, fuse adjacent loads, and sink stores past
unrelated code.
So there are two independent reorderers in the stack. A fence stops the hardware and does nothing about the compiler; a compiler barrier does the reverse. The atomic operations in modern languages emit both, which is the main reason to use them rather than hand-rolled assembly.
Why DRF-SC is the right bargain. The alternative — requiring hardware and compilers to preserve sequential consistency — costs roughly a fence at every shared access and forbids most optimization. Measurements at the time put naive SC enforcement at large slowdowns on weakly-ordered machines. The alternative in the other direction, specifying exactly which reorderings are visible, forces every programmer to learn a specific architecture.
DRF-SC threads between them: correctly synchronized programs, which is nearly all programs, may be reasoned about with the simple model, while implementations keep full freedom to reorder anything not ordered by synchronization. The catch is that “data race” is defined in terms of SC executions, so the definition is subtly circular-looking, and a program must be race-free under all SC executions, not just typical ones.
What acquire and release actually mean. The ordering primitives are not about single variables, they are about everything else.
- Release on a store: no prior memory operation in program order may be reordered after it. Publishing.
- Acquire on a load: no subsequent operation may be reordered before it. Subscribing.
- A release store observed by an acquire load creates a happens-before edge (T058), and everything before the release is visible after the acquire.
The initialization bug is now nameable. config = ...; ready = true; needs the
store to ready to be a release, and if (ready) needs the load to be an
acquire. With those, the write of config happens-before the read of it. Without
them, there is no edge and the compiler or CPU may order them however it likes.
Sequentially consistent atomics add a single total order across all such
operations, which is stronger than acquire/release and is what restores the
store-buffer litmus test to three outcomes, at the cost of a real fence.
volatile is the single largest source of confusion here, and the answer
differs by language. In C and C++, volatile means “do not optimize away this
access” and was designed for memory-mapped device registers. It provides no
ordering and no atomicity, and using it for thread communication is a bug. In
Java, volatile was strengthened by JSR-133 in Java 5 into a genuine
sequentially-consistent atomic with release/acquire semantics, and it is the
correct tool. The keyword is spelled the same and means different things, which
is why advice copied between the two languages is so often wrong.
Why double-checked locking was broken and then fixed. The famous idiom failed
in Java before Java 5 because the write publishing the object reference could be
reordered before the writes initializing its fields, letting another thread
observe a non-null reference to a partly-constructed object. The fix was not a
cleverer idiom, it was a memory model: declare the field volatile and JSR-133
guarantees the ordering. The same fix in C++11 is std::atomic with release and
acquire, and in both cases the repair came from the language gaining a
specification rather than from programmers gaining a trick.
Forbids
Reasoning about lock-free code by reading the source in program order. The executed order is not the written order. Only synchronization edges constrain it.
Using non-atomic variables for thread communication. A data race is undefined behaviour in C++ and effectively unpredictable in every language. Correct-looking racy code is still incorrect.
Assuming code that works on x86 works on ARM. TSO is far stronger than ARM’s model. A large fraction of concurrency bugs surface only on the port, which is precisely what the Apple Silicon and Graviton transitions produced.
Using C or C++ volatile for synchronization. No ordering, no atomicity, no
guarantee. It prevents one specific optimization and nothing more.
Does not forbid
It does not mean you need fences everywhere, which is the over-correction.
DRF-SC says race-free programs get sequential consistency. A program that uses
mutexes correctly needs no fences at all, because lock and unlock already carry
acquire and release. std::mutex, synchronized, and pthread_mutex_t are
complete solutions.
It does not mean weaker orderings are premature optimization. Relaxed atomics
are exactly right when you want atomicity without ordering. A statistics counter
incremented with memory_order_relaxed is correct and materially faster, and
reference counting increments use relaxed with only the decrement needing
release/acquire, which is what std::shared_ptr does.
It does not forbid useful reasoning tools. Litmus testing is a mature field.
The herd7/diy tools model ARM, POWER, x86, and RISC-V formally, and the
Linux kernel ships an executable memory model in tools/memory-model that
klitmus can run. These are checkable specifications, not folklore.
It does not mean sequential consistency is unavailable. memory_order_seq_cst
is the default in C++ for a reason, and Java’s volatile is SC. You pay a fence
and stop thinking about it, which is usually the right trade.
It does not make all reordering visible. Single-threaded semantics are always preserved, and reorderings are only observable across a race. Most programs never observe any of this, which is why the bugs are so startling when they finally appear.
Boundary
- DRF-SC only helps race-free programs. Racy C++ is undefined; racy Java is bounded but weak, and the Java model’s treatment of out-of-thin-air values is still not fully satisfactory.
- The model is a language contract, not a hardware description. Hardware models are separate, more detailed, and vary by architecture.
- Sequential consistency is not linearizability. SC has no real-time constraint; see T055.
- Compiler and hardware are independent. Both must be constrained, and a solution addressing only one is incomplete.
- Out-of-thin-air remains open. Formalizing a model that permits real optimizations and forbids self-justifying speculative values is an active research problem, and the C++ standard currently forbids OOTA only in prose.
The claim to carry: shared memory is an interface with a specification, and the
specification is DRF-SC. You buy the simple model by eliminating races, not by
adding volatile until the symptom stops.