C++ / CONCURRENCY AND PARALLELISM
Data races and why they are undefined behaviour
Identify when two threads' accesses form a data race, explain why C++ makes it undefined behaviour, and reproduce and detect one deliberately.
What you will learn
- Name the four conditions that make two accesses a data race in C++
- Explain why a race is UB for the whole program, not just a wrong value
- Tell separate memory locations apart from bit-fields that share one
- Reproduce a lost update deliberately and detect races with -fsanitize=thread
Understanding Data races and why they are undefined behaviour
C++ defines a data race precisely: two accesses to the same memory location from different threads, where at least one is a write, neither access is atomic, and nothing orders them by happens-before. A memory location is one scalar object, or one maximal run of adjacent non-zero-width bit-fields, which is why writing a.x in one thread and a.y in another is fine for plain members but is a race for two bit-fields packed into the same sequence. If an execution of the program contains a data race, the standard says the behaviour of the whole program is undefined, not merely the value of that one variable.
The reason is that every optimisation applied to non-atomic data assumes the current thread is the only one touching it. A compiler may keep a variable in a register across a loop, reload it twice and legitimately produce two different values, split one 64-bit store into two narrower stores, or move a store past an unrelated one, because none of that is observable without a second thread. Once behaviour is undefined the compiler may additionally assume the race cannot happen and remove code that only makes sense if it does. So a lost increment is a symptom, not a specification: the same race can yield a value that appears nowhere in the source, a loop that terminates at -O0 but spins forever at -O2, or a half-written pointer that crashes on dereference.
The mental model that scales is ownership: at any moment a non-atomic object has exactly one thread permitted to touch it, and that permission is transferred only by a happens-before edge, such as starting a thread, joining it, releasing a mutex another thread then acquires, an acquire load reading a release store, or a future becoming ready. For every shared variable, ask which edge separates the two conflicting accesses; if you cannot point at one, the race is real however short the window looks. Testing is a weak detector because the damaging interleaving may need unusual timing, so build with ThreadSanitizer (-fsanitize=thread), which reports races it observes even on runs that printed the right answer.
Data races are undefined behaviour because the compiler and hardware are allowed to treat non-atomic memory as private to one thread, so no rule constrains what a racing program does.
<chrono>
<iostream>
<thread>
int shared = 0; // plain int, touched by two threads, guarded by nothing
void racy_increment() {
int local = shared; // unsynchronised read
// The sleep does not create the race; it widens the window so the bad
// interleaving happens on every run instead of one run in a million.
std::this_thread::sleep_for(std::chrono::milliseconds(100));
shared = local + 1; // unsynchronised write of an already stale value
}
int main() {
std::thread a(racy_increment);
std::thread b(racy_increment);
a.join();
b.join();
std::cout << "expected: 2\n";
std::cout << "shared: " << shared << '\n';
}
A data race makes the entire program undefined behaviour, because non-atomic objects may only be shared between threads across an explicit happens-before edge.
Worked examples
Separate members are separate memory locations
Two threads writing to two different int members of the same object is not a data race, and the totals are guaranteed.
<iostream>
<thread>
struct Counters {
int hits = 0;
int misses = 0;
};
int main() {
Counters c;
std::thread t1([&c] { for (int i = 0; i < 100000; ++i) ++c.hits; });
std::thread t2([&c] { for (int i = 0; i < 100000; ++i) ++c.misses; });
t1.join();
t2.join();
std::cout << "hits=" << c.hits << " misses=" << c.misses << '\n';
}
Example explained
Line 1c.hits and c.misses are distinct scalar objects, so they are distinct memory locations and the concurrent writes never conflict.
Line 2Rewrite the members as int hits : 16; int misses : 16; and both threads now write one memory location, which is a data race and undefined behaviour.
Line 3The two members almost certainly share a cache line, so the cores ping-pong it; false sharing costs time but is not UB and cannot corrupt the counts.
Line 4t1.join() and t2.join() supply the happens-before edge that makes the final reads in main well defined.
A thread that only reads still races
Shows an unsynchronised reader observing an invariant that is broken halfway through another thread's update.
<chrono>
<iostream>
<thread>
using namespace std::chrono_literals;
int in_stock = 100;
int reserved = 0; // invariant: in_stock + reserved == 100
int main() {
std::thread writer([] {
in_stock = 90; // invariant broken here
std::this_thread::sleep_for(100ms);
reserved = 10; // invariant restored here
});
std::this_thread::sleep_for(50ms); // land inside the update
int a = in_stock; // unsynchronised read
int b = reserved; // unsynchronised read
std::cout << "seen by reader: " << a + b << '\n';
writer.join();
std::cout << "after join: " << in_stock + reserved << '\n';
}
Example explained
Line 1in_stock = 90; leaves the two-variable invariant temporarily false, and nothing hides that window from other threads.
Line 2The reads of in_stock and reserved in main are unordered with respect to the writer's stores, so they are data races even though main never writes.
Line 3The 50ms sleep only makes the bad interleaving reproducible; deleting it removes the reproducibility, not the race or the UB.
Line 4The final read is legal because writer.join() establishes happens-before, so both stores are visible to main.
Important notes
A data race is narrower than a race condition: code that puts every access under a mutex has no data race yet can still be wrong, for example a check in one lock scope acted on in the next.
These examples need -pthread on GCC and Clang; without it the program may fail to link or misbehave at run time for reasons unrelated to the race being demonstrated.
Common mistakes
Assuming ++count on an int is one indivisible step: it is load, add, store, so concurrent increments silently drop updates, and because the program is now UB the wrong total is not the only possible damage.
Believing a read-only thread cannot race: a read concurrent with a write is a data race, and the reader may see a half-updated object or a value the compiler cached in a register, which is how a spin on a plain bool becomes an infinite loop at -O2.
Treating volatile, a sleep, or ten thousand passing runs as synchronisation: volatile gives no atomicity and no ordering against other threads, so the race and its undefined behaviour survive unchanged.
Try it yourself
Change, predict, then run
Replace the sleeping increment in the main example with two threads that each execute ++shared 100000 times, drop the sleeps, and run the program five times. Record the five totals, then write one sentence explaining why a run that prints exactly 200000 does not make the program correct.
Open the C++ workspaceCheck your understanding
Two threads increment a plain int with no synchronisation, and one run prints the exactly correct total. What can you conclude?
- The increments were atomic on this CPU, so this build is safe.
- Nothing useful: the execution still contains a data race, so the program is undefined behaviour whatever it printed.
- The race is benign, because unsynchronised increments can only lose updates and never invent values.
- The compiler inserted a lock, since it can see both threads touching the same object.
Show answer
Undefined behaviour follows from the unordered conflicting accesses themselves, not from the value a particular run happens to print, so a correct-looking result proves nothing. Option 3 is tempting because lost updates are the usual symptom, but a compiler entitled to assume no race can also hold the variable in a register, fuse or reorder the stores, or drop a reload, so 'only lost updates' is not a guarantee the standard makes.