Why Volatile Fails at Thread Synchronization in C

In modern C programming, the volatile type qualifier informs the compiler that an object's value can change through mechanisms outside the program's control, such as hardware registers or asynchronous signal handlers, forcing the compiler to generate actual read and write instructions rather than caching values in registers. However, volatile is fundamentally unsuited for multithreaded synchronization because it does not guarantee atomic operations, does not prevent hardware-level CPU instruction reordering, and does not enforce memory visibility across CPU cache hierarchies. Modern multithreaded applications require strict concurrency controls, which C satisfies through atomic types and operations defined in standard libraries rather than through volatile.

Lack of Atomicity

The volatile qualifier does not make read, write, or read-modify-write operations atomic. If a thread increments a variable declared as volatile int counter; using counter++, the operation typically compiles into three distinct machine instructions: reading the value from memory into a CPU register, incrementing the register, and writing the value back to memory.

If multiple threads execute this sequence simultaneously, these instructions can interleave arbitrarily, resulting in lost updates and race conditions. Furthermore, on platforms where memory access widths are narrower than the variable's type, reading or writing even a plain assignment to a volatile variable can be split into multiple bus cycles, exposing other threads to torn reads and writes.

No CPU Memory Barriers or Fences

While volatile prevents the compiler from optimizing away memory accesses or reordering them past other volatile accesses at compile time, it places zero constraints on the CPU itself. Modern multi-core architectures employ out-of-order execution, speculative execution, store buffers, and complex cache-coherency protocols to maximize performance.

To ensure that operations performed by one CPU core become visible to another core in a predictable sequence, hardware-level memory barriers (or memory fences) are required. The C standard does not require volatile accesses to emit memory fence instructions (such as MFENCE on x86 or DMB on ARM). Consequently, even if the compiler preserves the source code's access order, the underlying hardware can reorder reads and writes, leading to situations where dependent data is read before a synchronization flag is set.

Cache Coherence and Visibility Guarantees

A common misconception is that volatile forces a variable to bypass CPU caches and flush directly to main memory. In reality, modern hardware relies on hardware-level cache coherency protocols (such as MESI) to synchronize cache lines between cores.

Because volatile does not generate the store-buffer-draining instructions required to ensure immediate visibility across cores, writes to a volatile variable can remain buffered locally within a core's private store buffer. Another core running a concurrent thread may continue to read an outdated value from its own cache until the buffer naturally flushes, breaking lock-free patterns such as spinlocks or double-checked locking.

Intended Use vs. Thread Safety

The ISO C standard historically designed volatile to handle three specific scenarios:

  • Accessing memory-mapped input/output (MMIO) hardware registers.
  • Retaining variable state across setjmp and longjmp calls.
  • Accessing variables shared with asynchronous signal handlers running in the same thread.

The standard explicitly does not define volatile in terms of concurrent threads of execution. Using volatile for inter-thread synchronization relies on non-portable, compiler-specific behavior that fails on modern weak-memory-model architectures.

The Standardized Alternative

For safe and portable inter-thread communication, modern C (beginning with the C11 standard) provides the <stdatomic.h> library and the _Atomic type qualifier. These primitives allow developers to specify explicit memory ordering semantics—such as acquire-release or sequentially consistent operations—which instruct both the compiler and the target CPU architecture to enforce the exact visibility, atomicity, and ordering required for correct multithreaded synchronization.