Module 05 · Synchronization · Lesson 1 of 6
Race conditions and critical sections
Why counter++ on two threads loses updates: read-modify-write, interleavings, and the critical section that must run alone. The requirements for a correct solution, check-then-act races.
Why it matters
Race conditions are the bugs that pass every test, ship, and then corrupt data once a week under load. Lost counter updates, double-booked seats, two threads both initialising a cache, a balance that goes negative: the same pattern underlies all of them, inside one process with threads and across a fleet of servers sharing a database. Recognising the pattern is the first skill; every synchronization tool in this module exists to remove it.
The idea
A race condition occurs when the result of a computation depends on the timing of operations in concurrent threads, and some timings give the wrong answer. The classic example is two threads each running count = count + 1 a million times on a shared counter. The final value is usually well under two million.
Why counter++ is not one step
To the CPU, incrementing a variable in memory is three steps: read the value into a register, add one, write it back. If two threads interleave those steps, one update overwrites the other.
Critical sections
A critical section is a piece of code that accesses shared state and must not run in more than one thread at a time. Any correct way of protecting one must provide:
- 1
Mutual exclusion
At most one thread is inside the critical section at any moment. - 2
Progress
If no thread is inside and some want to enter, one of them gets in; the system does not stall. - 3
Bounded waiting
A thread that wants to enter will get in after a bounded number of other entries; no thread starves.
The tools that provide this, locks, semaphores, monitors and atomic instructions, are the rest of this module. Keeping critical sections small (only the shared-state access, never the I/O around it) is what keeps them from becoming bottlenecks.
The shapes races take
| Pattern | Example | Why it breaks |
|---|---|---|
| Read-modify-write | count++, balance -= amount | Another thread's update lands between the read and the write |
| Check-then-act | if seat is free, book it; if cache is empty, fill it | The condition changes between the check and the act |
| Publish before initialise | Set a shared pointer, then fill in the object | Another thread sees the pointer and reads a half-built object |
| Compound invariants | Move money between two accounts | Another thread sees the state between the two updates |
| Lazy initialisation | Create the singleton on first use | Two threads both create one |
Check-then-act is the one that escapes into distributed systems: "if no row exists for this email, insert one" run on two servers creates two accounts unless a unique constraint or a transaction makes the check and the act atomic.
Visibility, not just atomicity
Modern CPUs and compilers reorder memory operations and keep values in registers and per-core caches. Without synchronization, a write by one thread may become visible to another late or, in some languages, never (a loop waiting on a flag that never sees it change). Language memory models (Java's, C++'s, Go's) define which operations create a happens-before relationship that guarantees visibility: locking and unlocking a mutex, writing and reading a volatile or atomic, starting and joining a thread. A program with unsynchronized concurrent access to the same memory where at least one access is a write has a data race, which in C and C++ is undefined behaviour and in Java permits surprising results.
Why testing does not find them
A race needs a specific interleaving, and on a lightly loaded laptop that interleaving may happen once in billions of runs. Under production load, with more cores, more contention and different timing, it happens daily. Tools help: thread sanitizers (TSan, Go's race detector) instrument memory accesses and flag unsynchronized conflicting accesses when they occur, stress tests raise contention, and model checkers explore interleavings exhaustively for small pieces of code.
Trade-offs
Every synchronization mechanism trades parallelism for correctness: a critical section serialises the threads that need it. Large critical sections are easy to get right and kill throughput; tiny ones scale and are easy to get wrong. Avoiding shared mutable state altogether (immutability, message passing, per-thread data) removes races by design and is often the best trade.
In an interview
Name the pattern and its fix: "Booking is check-then-act: two requests can both see the seat free. The check and the insert must be atomic, so we rely on a unique constraint on (show, seat) and treat the violation as 'already taken', rather than checking first in application code." The same answer covers both threads and distributed servers.
Check yourself
Two threads each increment a shared integer 1,000,000 times with count = count + 1 and no synchronization. Which outcome is possible?