C++ / a working model

06 / 163   ·   C++11   ·   8 min

volatile: observable access, not thread synchronization

Keep this sentence

volatile tells the implementation that the related accesses have observable effects and must not be discarded as ordinary memory accesses. It does not provide atomicity, inter-thread ordering, or happens-before, and it does not promise to bypass CPU caches; thread communication should use atomic operations or locks.

In this lesson
  1. What it constrains is access semantics
  2. Why it cannot replace atomic
  3. Draw the boundary with a safe example
  4. Example
  5. Exercise

What it constrains is access semantics

A read or write of an object through a volatile-qualified access path is an observable access that the implementation must preserve according to the corresponding rules. Typical uses are platform-defined memory-mapped device registers and signal-handling scenarios that meet special restrictions. The qualifier does not give an ordinary variable some "more real-time" property, and it is not a general-purpose hint that the compiler should treat every nearby load or store as sacred.

Which hardware addresses are valid, what bus operation a single access corresponds to, and whether a device barrier is required all depend on the implementation and on platform documentation. Standard C++ does not promise that volatile must bypass CPU caches. It also does not promise that two ordinary memory accesses will acquire a hardware order merely because a volatile access appears nearby. The abstract machine requires certain accesses to be kept; it does not turn volatile into a portable cache-control or device-driver language of its own.

Why it cannot replace atomic

A volatile read or write does not automatically become an indivisible operation, and it does not establish happens-before between threads. Two threads that conflict on the same volatile int without synchronization can still have a data race. Repeatedly reading a volatile flag also cannot safely publish nearby ordinary data. The flag access may be preserved as an observable access and still fail to order, or even to make atomic, the surrounding non-volatile operations.

A shared counter should use a mutex or std::atomic. If you only tally and do not publish other state, an atomic fetch_add with relaxed ordering is often enough. If a flag is responsible for publishing data, you must design release/acquire or similar synchronization so that the write of the payload happens-before the read of the payload. The point is to make the communication protocol explicit, not to swap keywords mechanically and hope that volatile will stand in for an atomic object or a lock.

Draw the boundary with a safe example

The example writes and reads a local volatile object on a single thread, showing a legal qualified access. Separately it uses an atomic counter to show an independent atomic operation. It does not fake a device address, start threads that race, or claim that this program verifies a real hardware driver. Keeping those concerns apart is the lesson: volatile access and atomic synchronization answer different questions.

Since C++20, uses such as volatile increment, decrement, and some compound assignments are deprecated. New code should not write volatile ++x to express synchronization. Signal handling must also obey asynchronous-signal-safety limits. The narrow role of a common volatile std::sig_atomic_t must not be generalized into ordinary multithreaded shared state. A signal handler is not a worker thread, and the restrictions that make a signal flag usable do not become a memory model for two threads sharing a buffer.

Pitfalls

  • volatile is not a cache-flush instruction; explaining it as "always read from main memory" confuses the language abstract machine with a particular machine.
  • Do not strip the qualification from a truly volatile object and then read it through an ordinary access path; that bypass breaks the language rules rather than merely lowering the optimization level.

Run an example

Minimum C++11 · complete program · Download .cpp

#include <atomic>
#include <cassert>
#include <iostream>

int main() {
    volatile int observed = 0;
    observed = 7;
    const int snapshot = observed;
    std::atomic<int> count{0};
    const int previous = count.fetch_add(1, std::memory_order_relaxed);
    assert(snapshot == 7);
    assert(previous == 0);
    assert(count.load(std::memory_order_relaxed) == 1);
    std::cout << snapshot << ' ' << count.load() << '\n';
}

Compile locally

g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread basics-volatile.cpp -o example && ./example

Expected result

7 1

CHECK YOUR UNDERSTANDING

Close the answer. Explain it.

A worker thread writes data = 42 and then sets volatile bool ready; the main thread waits for ready and then reads data. Why is this scheme wrong? Give a correct flag protocol.

Show a reference answer

volatile ready itself is not a thread-safe synchronization object, and it does not protect data. Change ready to std::atomic<bool>. The worker writes data first, then ready.store(true, std::memory_order_release); the reader waits until ready.load(std::memory_order_acquire) returns true and then reads data. data must have no later unsynchronized write; the release and acquire then establish a visibility relationship.

Check the sources

Drafts and official chapters change. The version mark is only the example’s minimum.

Back to the catalog