Atomics and the Memory Model
Sharing data between threads without locks, correctly
std::atomic<T> gives you operations that are indivisible (no thread ever sees a half-written value) and, just as importantly, lets you control the ordering in which one thread's writes become visible to another. This topic is about both halves: atomicity and the memory model that defines "visible".
- state precisely what a data race is and why it is undefined behavior;
- use
std::atomic<T>-load,store,exchange,fetch_add,compare_exchange; - explain the happens-before relationship and how it makes a write visible;
- reason at an intuition level about the memory orderings
seq_cst,acquire/release, andrelaxed.
1. Data races are undefined behavior
A data race occurs when two threads access the same memory location concurrently, at least one access is a write, and the accesses are not ordered by any synchronization. The C++ standard says a program with a data race has undefined behavior (UB). That is stronger than "you might read a stale value" - the compiler and CPU are allowed to assume races never happen, so a race can produce torn values, lost updates, or code that is optimized in ways that make no sequential sense.
int counter = 0; // plain int, shared
void worker() {
for (int i = 0; i < 100000; ++i)
++counter; // read-modify-write, NOT atomic -> DATA RACE
}
// Two threads running worker() race on counter.
// The final value is unpredictable and the program is UB.
++counter looks like one step but is really three: load, add one, store. Two threads can both load the same value, both add one, and both store - so two increments become one. Making counter an int guarded by nothing does not just risk a wrong total; it is a race, and races are UB.
std::mutex is not racing either - the lock provides the ordering.