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
setjmpandlongjmpcalls. - 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.