Back to the map

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.

CoreMust-knowJunior 12 minVideo Updated Nov 2026
Video walkthrough · 2:35 · narrated, with captions

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.

Thread AThread Bcount41read → 41read → 41write 42write 42expected 43final 42
Both threads read 41, both compute 42, both write 42. Two increments, one effect: a lost update.

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. 1

    Mutual exclusion

    At most one thread is inside the critical section at any moment.
  2. 2

    Progress

    If no thread is inside and some want to enter, one of them gets in; the system does not stall.
  3. 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

PatternExampleWhy it breaks
Read-modify-writecount++, balance -= amountAnother thread's update lands between the read and the write
Check-then-actif seat is free, book it; if cache is empty, fill itThe condition changes between the check and the act
Publish before initialiseSet a shared pointer, then fill in the objectAnother thread sees the pointer and reads a half-built object
Compound invariantsMove money between two accountsAnother thread sees the state between the two updates
Lazy initialisationCreate the singleton on first useTwo 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?

Race conditions and critical sections · Backplane