← All posts
language

volatile: What It Does and What It Absolutely Does Not Do

volatile exists to solve one problem: memory that changes without your program touching it. Hardware status registers, memory-mapped I/O, signal handlers touching variables from outside normal control flow. For that problem, volatile is the right tool. For threading, it is not just insufficient, it is the wrong mental model applied to a different class of problem.

What the Standard Actually Says

The C++ standard defines volatile in terms of observable behavior and the as-if rule. The as-if rule permits the compiler to transform code in any way it likes, as long as observable behavior is preserved. Observable behavior is defined as the sequence of reads and writes to volatile objects and calls to I/O library functions.

This definition has a precise consequence: a volatile access cannot be eliminated, combined with another volatile access, or reordered relative to other volatile accesses. The compiler must emit code that accesses the volatile location as many times as the source specifies, in the order the source specifies, relative to other volatile accesses.

The gap is in the phrase “relative to other volatile accesses.” The standard does not say volatile accesses must be ordered relative to non-volatile accesses. A volatile write can still be reordered before or after a non-volatile write nearby. This is the precise technical reason volatile fails as a threading primitive: the ordering guarantee it provides is only between volatile operations themselves, not between volatile and everything else.

The Codegen Proof

The compiler behavior is verifiable directly. Two functions read the same address twice:

int read_twice_normal(int& x) {
    int a = x;
    int b = x;
    return a + b;
}
/* compiles to:
   mov  eax, [rdi]      ; one read
   add  eax, eax        ; doubled, second read eliminated
   ret
*/

int read_twice_volatile(volatile int& x) {
    int a = x;
    int b = x;
    return a + b;
}
/* compiles to:
   mov  eax, [rdi]      ; first read
   mov  edx, [rdi]      ; second read, not eliminated
   add  eax, edx
   ret
*/

read_twice_normal compiles to a single load followed by add eax, eax. The compiler saw two reads of the same reference with no intervening write and concluded the second read was redundant. For a normal variable in a single-threaded context, that conclusion is correct.

read_twice_volatile compiles to two separate loads. The volatile qualifier tells the compiler that the memory at that address may change between reads, so both must hit memory. Three instructions instead of two, and the second read is no longer eliminated.

const volatile: The Embedded Pattern

const and volatile are orthogonal qualifiers and can be applied together. The combination has a specific meaning that is not obvious from either qualifier alone:

const volatile uint32_t* status = (const volatile uint32_t*)0x40020000;

const means the software cannot modify the value through this pointer. A write through status is a compile error. volatile means the compiler cannot cache the value in a register across reads. Every read through status must fetch from the actual memory address.

Together: the software treats the location as read-only, but the hardware may update it at any time. This is exactly the pattern for a hardware status register: your driver code reads it but never writes it, while the device updates it whenever its state changes. Without volatile, the compiler would cache the value from the first read and your polling loop would spin forever on a stale value. Without const, a programming mistake could attempt to write to a hardware register that expects no writes.

const volatile uint32_t* ready_flag = (const volatile uint32_t*)0x40020004;

// polling loop: every iteration re-reads from hardware
while ((*ready_flag & 0x01) == 0) { }

// write attempt: compile error, const prevents it
*ready_flag = 1;  // error: assignment of read-only location

Three Separate Gaps for Threading

The intuition that volatile helps with threading looks plausible on the surface: if a thread-cached read is the problem and volatile forbids caching, it seems like the fix. There are three distinct reasons it is not.

Atomicity: volatile makes no operation atomic. A read-modify-write on a volatile int, such as counter++, is still three operations: load, increment, store. Two threads can interleave those operations on a volatile variable exactly as they would on a non-volatile one. No processor instruction guarantees that a non-atomic read-modify-write is indivisible, and volatile has no effect on the instruction sequence the processor executes.

Cross-memory ordering: volatile only orders volatile accesses relative to each other. A volatile write to a flag variable does not prevent the compiler from moving a non-volatile write to the protected data after the flag write. The data write and the flag write can be emitted in any order the compiler finds convenient, as long as the volatile writes are in the right order relative to each other.

Hardware visibility: volatile is a compiler directive. It says nothing to the processor. On architectures with weak memory ordering, ARM and POWER in particular, the processor itself can reorder stores and make them visible to other cores out of order. A volatile write that the compiler emits in the correct order can still become visible to another core in a different order because the memory subsystem has no instruction telling it to flush or order the write. volatile was not designed to communicate with another core. It was designed to communicate with a compiler that might otherwise eliminate a read.

CERT Secure Coding rule CON02-C states this explicitly: “Do not use volatile as a synchronization primitive.” It is a named, documented anti-pattern.

The Classic Broken Example

volatile bool ready = false;
int data = 0;

// Thread 1:
data = 69;          // non-volatile: compiler can move this
ready = true;       // volatile write

// Thread 2:
while (!ready) { }  // volatile read in a spin loop
use(data);          // is data guaranteed to be 69 here?

The answer is no, for all three reasons above. The compiler can reorder data = 69 below ready = true since data is not volatile and the as-if rule permits it. Even if the compiler emits them in the written order, the processor on a weakly-ordered architecture can make the ready = true write visible to Thread 2 before the data = 69 write is visible. Thread 2 exits the spin loop, reads data, and gets an indeterminate value.

The correct version uses std::atomic with explicit ordering:

std::atomic<bool> ready{false};
int data = 0;

// Thread 1:
data = 69;
ready.store(true, std::memory_order_release);

// Thread 2:
while (!ready.load(std::memory_order_acquire)) { }
use(data);   // guaranteed to see 69

The release-acquire pair establishes a synchronizes-with relationship that the volatile version cannot. data = 69 happens-before ready.store in the C++ memory model, and ready.load reading true synchronizes with that store, guaranteeing Thread 2 sees data == 69. This is what volatile was never designed to provide.

Cross-Language Confusion

C# and Java both have a volatile keyword, but its semantics differ from C++’s in ways that create genuine confusion for programmers moving between languages.

Java’s volatile provides a visibility guarantee: a write to a volatile field is visible to all threads that subsequently read that field. It establishes a happens-before relationship and effectively acts as a memory barrier for that specific variable. Java’s volatile does solve the classic flag-based visibility problem that C++’s volatile cannot.

C#’s volatile is similar: reads carry acquire semantics, writes carry release semantics, making it closer to std::atomic with acquire/release ordering than to C++’s volatile.

A programmer coming from Java or C# who reaches for volatile in C++ to solve a threading visibility problem is applying a tool whose name matches but whose semantics do not. The keyword looks the same. The guarantee is completely different. This is the most common source of the misconception.

What Actually Replaces It

The three gaps volatile cannot close each have a specific tool:

Atomicity: std::atomic<T> makes read-modify-write operations on T indivisible. counter.fetch_add(1) is guaranteed to be atomic. No interleaving is possible between the read and the write.

Cross-memory ordering: std::memory_order arguments on atomic operations, or std::atomic_thread_fence, establish the ordering guarantee between volatile and non-volatile operations that volatile cannot. A release store on an atomic flag prevents any prior write (volatile or not) from being reordered past it. The memory ordering and fences posts in this series cover this in full detail.

Hardware visibility: the same acquire/release or seq_cst operations that establish ordering also communicate with the hardware on weakly-ordered architectures. A release store emits a fence instruction if the architecture requires it. volatile emits no such instruction.

For memory-mapped I/O and hardware registers in embedded code: volatile is still correct. That is what it was designed for. For threading: use std::atomic with appropriate memory ordering. The two tools solve genuinely different problems.

Quick Reference

Coming from other languages

If you are coming from Java or C#, volatile in C++ is not the same keyword. Java’s volatile provides visibility and ordering across threads. C#’s volatile provides acquire/release semantics. C++’s volatile prevents the compiler from caching or eliminating reads and writes to the marked location. That is all it does. The threading guarantees you expect from Java or C# volatile do not exist in C++ volatile, and code that relies on them will have data races.

The 90% mental model

volatile tells the compiler: this memory location may change without your knowledge, so every read must go to memory and every write must go to memory, no caching in registers, no elimination of reads that look redundant. This is the right tool for hardware registers, memory-mapped I/O, and variables touched by signal handlers. It is the wrong tool for threading: it does not make operations atomic, does not order volatile accesses against non-volatile accesses, and does not emit any hardware instruction to control visibility across cores. For threading, use std::atomic with explicit memory ordering. const volatile together is the embedded pattern for a location the software reads but never writes, while hardware may update it at any time.