Someone marks a shared flag volatile, the race goes away on their machine, and
a myth is born: volatile makes things thread-safe. It doesn’t. It solves a
different problem, and the bug it hides comes back the moment the hardware
reorders your memory accesses.
What volatile Actually Promises
volatile tells the compiler: this value can change behind your back, so don’t
cache it in a register and don’t optimize away reads or writes. That’s it. It was
designed for memory-mapped hardware registers, not threads.
volatile int ready; // compiler will re-read it every time
while (!ready) { } // won't be hoisted into "while(true)"This stops one class of bug — the compiler assuming a value never changes. It says nothing about ordering or atomicity.
The Reordering You Can’t See
Both the compiler and the CPU may reorder independent memory operations for speed. Consider the classic publish pattern:
data = 42; // (1) write the payload
ready = 1; // (2) announce it's readyEven with ready volatile, nothing guarantees thread B sees (1) before (2). The
CPU’s store buffer can make ready = 1 globally visible before data = 42.
The reader then sees ready set and reads garbage from data:
writer (CPU 0) reader (CPU 1)
store data = 42 ──┐ load ready → 1
store ready = 1 ──┘ load data → ??? (maybe still 0)
store buffer may reorder these as seen by CPU 1This is not a compiler bug. It’s the memory model the hardware is allowed to use.
What a Barrier Does
A memory barrier (fence) constrains that ordering. The two that matter for the publish pattern:
- Release on the writer: everything before it is visible before the flagged store.
- Acquire on the reader: nothing after it is reordered before the flagged load.
Pair them and you get a happens-before relationship across threads:
#include <stdatomic.h>
// writer
data = 42;
atomic_store_explicit(&ready, 1, memory_order_release);
// reader
if (atomic_load_explicit(&ready, memory_order_acquire) == 1)
use(data); // guaranteed to see 42The acquire/release pair is the actual fix. It’s also usually cheaper than a full barrier, because it only forbids the reorderings that would break you.
The Rule of Thumb
- Use
volatilefor memory-mapped I/O and signal handlers. Not for threads. - Use atomics with explicit ordering for cross-thread communication. Default to
seq_cstuntil you can prove acquire/release is enough. - If two threads touch the same non-atomic location and one writes, that’s a data race — undefined behavior, full stop.
Takeaways
volatileprevents the compiler from caching a value; it does not provide ordering or atomicity.- Real bugs come from compiler and CPU reordering of independent accesses.
- Acquire/release barriers (via atomics) establish happens-before across threads and are the correct, often cheaper, tool.