Threads and race conditions
Two threads increment the same counter and the answer is wrong. The bug is a schedule, not a line of code — and that is exactly why it is hard.
Start with the smallest possible parallel program: two threads, each doing
x = x + 1 once on a shared integer that starts at zero. When both finish, x
should be 2. Run it a few times and it usually is. But not always. Occasionally
you get 1 — an increment simply vanished, even though no thread did anything
you could call wrong.
The reason is that x = x + 1 is not one action. It is three: load x
into a register, add one, store the register back. The two threads'
loads and stores can interleave in orders you did not intend, and one ordering
loses an update. This is the central lesson of concurrent programming: a
correct-looking program can be wrong because of its schedule.
Nothing is atomic unless you make it so
A line of source code is not a unit of execution. At the machine level almost everything is several steps, and the operating system, the CPU, and even the compiler may reorder or interleave them. "Atomic" is a property you must construct with a primitive, never one you get for free.
Step the two threads below in different orders. The same six operations give a different final value depending on when each thread reads.
Step either thread, one operation at a time — the schedule decides the answer
shared counter x
0
after both threads: expected 2
next op: load · regA = —
next op: load · regB = —
interleaving log
No operations yet. Step a thread to record what it did and what x became.
The read-modify-write is not one step: load and store are separate, and another thread can slip between them. Nothing here is exotic or malicious — the bug is the missing atomicity, and it is timing dependent, which is why it disappears in the debugger. This is a deterministic schedule chosen by hand, not a real race.
The interleaving is the program
If thread A has operations and thread B has , any order that preserves each thread's internal sequence is a legal execution. The count of such interleavings is the binomial coefficient
For the counter, gives schedules — and all but a few of them are correct. That is the cruel part: the buggy schedules are a minority, so the program passes the demo and fails in production. It gets worse fast. Two threads of ten operations have interleavings; you cannot test your way through the state space.
RAII-thinking says "it worked when I ran it", and the scheduler says "not today". Bugs that depend on timing are called heisenbugs for a reason: the debugger perturbs exactly the timing that triggers them.
Where the reorderings come from
It is tempting to think the load and store are far apart and interleaving is unlikely. The truth is worse: the hardware and compiler are allowed to reorder, and do, as long as a single-threaded observer cannot tell.
- Preemption — the OS switches threads between any two instructions.
- Memory reordering — CPUs and GPUs use store buffers and out-of-order execution, so a store may become visible to one thread before another.
- Compiler reordering — the optimiser may move loads and stores across each other because it assumes one thread.
- Caching — a write may sit in one core's cache unseen by others until it is flushed.
The last three mean that even two instructions with no context switch between them are not necessarily ordered as written. That is why a data race — two threads accessing the same location, at least one writing, without synchronisation — is not merely a bad pattern. In C++ and Go it is formally undefined behaviour: the compiler is free to assume it never happens.
Happens-before
The mental model that replaces "I hope the scheduler cooperates" is happens-before. Instead of reasoning about an actual timeline, you establish edges that guarantee ordering: an unlock happens-before a later lock of the same mutex, a thread start happens-before the new thread's first action, a join happens-before the joiner continues. If two accesses are not connected by a chain of such edges, they are concurrent, and concurrent access to shared mutable state is exactly what you must forbid.
This is the useful mental shift: stop asking "what order will this run in?" and start asking "which edges do I have, and is every shared access on one side of an edge?"
Careful
A race is not a performance problem you can tune later; it is a correctness bug with no reliable reproduction. Do not "fix" it by adding a sleep, a print, or a volatile keyword — those change timing without establishing an ordering edge. The only fixes are a synchronisation primitive or removing the sharing. And never test a race by running it many times; passing means you got lucky, not that it is correct.
Illustrative vs real
The simulator uses three coarse operations and a schedule you choose by hand, so the interleaving is fully deterministic and observable. Real execution is not so polite: the number of legal interleavings is astronomical, the hardware may reorder within a thread, and the same binary can behave differently on a different core count. The simulator teaches the mechanism; it cannot enumerate what your CPU will actually do.
Check yourself
Eduspheria wiki · Systems for AI, Concurrent programming
0 / 5 answered
From the exam paper
Modeled on NITJ AI-619, End-Sem June 2025
0 / 5 answered
Where next: the primitives — mutexes, semaphores and atomics — that create the ordering edges.