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.