C's volatile Thread-Safety Trap: The Keyword That Prevents Caching, Not Races

2026-09-12

This program launches two threads that each increment a shared counter one million times. The variables are marked volatile so the compiler won't stash them in registers. The final count should be exactly 2,000,000.

#include <pthread.h>
#include <stdio.h>

volatile long counter = 0;
volatile int  done    = 0;

static void *worker(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        counter++;          /* shared, but "volatile" — safe, right? */
    }
    done = 1;               /* signal completion to a watcher thread */
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, worker, NULL);
    pthread_create(&t2, NULL, worker, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("counter = %ld (expected 2000000)\n", counter);
    return 0;
}

Run it a few times on a multi-core box and you'll see numbers like 1348217, 1892044, 1210778. Never 2,000,000. On single-core hardware — or heavily optimized builds that happen to inline everything into one register — you might see the "correct" answer often enough to think the bug is flaky.

The Bug

volatile is a hint to the compiler, not a synchronization primitive. It tells the compiler:

That's all. It says nothing about:

The confusion comes from Java, where volatile genuinely does provide acquire/release semantics and a happens-before edge. C's volatile was designed decades earlier for memory-mapped I/O registers on single-core systems. The name overlap is a historical accident that has cost the industry uncountable debugging hours.

The Fix

Use C11 atomics, which guarantee both atomicity and (by default) sequential consistency:

#include <stdatomic.h>

atomic_long counter = 0;
atomic_int  done    = 0;

static void *worker(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        atomic_fetch_add(&counter, 1);
    }
    atomic_store(&done, 1);
    return NULL;
}

If C11 isn't available, use a mutex around the increment, or platform intrinsics like __atomic_fetch_add (GCC/Clang) or InterlockedIncrement (MSVC). Any of these produces both an atomic RMW instruction (lock xadd on x86) and the memory fences the hardware requires.

Reserve volatile for what it was actually designed for: memory-mapped hardware registers, signal handler flags of type sig_atomic_t, and setjmp/longjmp locals. Never as a substitute for synchronization.

Key Takeaway: In C, volatile stops the compiler from optimizing away accesses — it does not make them atomic or provide any inter-thread ordering; reach for <stdatomic.h> or a mutex whenever more than one thread touches the data.

All newsletters