Wiki
Intro10 min read

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

thread A · x = x + 1

next op: load · regA = —

thread B · x = x + 1

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 a1…ama_1 \dots a_m and thread B has b1…bnb_1 \dots b_n, any order that preserves each thread's internal sequence is a legal execution. The count of such interleavings is the binomial coefficient

(m+nn)=(m+n)!m! n!\binom{m+n}{n} = \frac{(m+n)!}{m!\,n!}

For the counter, m=n=3m = n = 3 gives (63)=20\binom{6}{3} = 20 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 (2010)=184,756\binom{20}{10} = 184{,}756 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

  1. 1Thread A has 3 operations and thread B has 3. How many distinct interleavings preserve each thread's order?
    Numeric answer
  2. 2Which of these is a data race?
    Multiple choice
  3. 3A program with a data race is well-defined but may produce an unexpected result.
    True / false
  4. 4What ordering relation, built from lock/unlock and thread start/join edges, is used to reason about concurrent accesses?
    Short answer
  5. 5Why is adding a sleep(10) before the write a bad fix for a race?
    Multiple choice

From the exam paper

Modeled on NITJ AI-619, End-Sem June 2025

0 / 5 answered

  1. 1Thread A has 4 operations and thread B has 4. How many distinct interleavings preserve each thread's internal order?
    Numeric answer
  2. 2Which type of parallelism applies the same operation to many independent data elements at once?
    Multiple choice
  3. 3Concurrency means several tasks are in flight during the same period, while parallelism means they literally execute at the same instant.
    True / false
  4. 4In the GPU thread hierarchy, the threads of a block are issued together in groups of 32 called…
    Multiple choice
  5. 5What name is given to a timing-dependent bug that tends to disappear when a debugger is attached?
    Short answer

Where next: the primitives — mutexes, semaphores and atomics — that create the ordering edges.