Race Conditions and the Critical Section Problem

Two bank tellers process a withdrawal from the same account at the same instant. Each reads the balance, subtracts an amount, and writes back a new value. Depending on the exact timing of these reads and writes, the account can end up with the wrong balance. Money silently vanishes or appears. This is not a bug in arithmetic; it is a bug in timing. It can vanish and reappear depending on machine load, scheduler mood, or compiler flags. This unpredictability is the defining signature of a race condition. Race conditions matter because nearly every modern program is concurrent. Operating system kernels manage multiple processes touching shared page tables and file descriptors. Web servers spawn threads that share caches and counters. Multicore CPUs let independent cores execute in parallel while reading and writing the same memory. Whenever two or more Threads and Shared Memory execute code that touches common state, the exact order of individual machine instructions is no longer under any single thread's control. If that order affects the outcome, the program's correctness now depends on scheduling. This is something the programmer does not control. The hardware does not guarantee it to be consistent, either. The critical section problem is the precise, formal way operating systems researchers frame this danger and its cure. Rather than asking "how do we avoid races" as a vague slogan, it isolates the exact region of code where shared data is touched. It names the properties a correct solution must have. It turns "be careful with shared state" into an engineering specification that can be verified. This framing is the seed from which locks, semaphores, monitors, and every synchronization primitive in a modern OS grow. This article builds the concept from the ground up: what a race condition actually is at the instruction level, why interleaving makes outcomes nondeterministic, how the critical section is defined, and how to reason rigorously about correctness of any proposed fix. It sets up, but deliberately stops short of, the actual algorithms (locks, Peterson's solution, semaphores) that solve the problem. Those belong to the mechanisms this problem statement motivates.

Key takeaways
  • A race condition occurs when the outcome of concurrent execution depends on the unpredictable timing/interleaving of operations on shared data.
  • Race conditions arise because seemingly atomic high-level statements (like `count++`) compile into multiple machine instructions that can be interleaved with another thread's.
  • The critical section is the code segment where a process/thread accesses shared resources that must not be concurrently accessed by others.
  • A correct solution to the critical section problem must guarantee mutual exclusion, progress, and bounded waiting.
  • Reasoning about races requires thinking in terms of an interleaving (an execution schedule), not just the source code as written.
  • This problem motivates all synchronization mechanisms: Locks and Mutual Exclusion, semaphores, and monitors are all attempts to satisfy the critical section requirements.
The core insight

When multiple threads share mutable state, correctness cannot depend on any particular interleaving of their instructions — it must hold for every interleaving the scheduler could produce.

The idea

A race condition is a defect where the correctness of a program's output depends on the relative timing or interleaving of multiple threads' or processes' operations on shared data. The word "race" is apt: two (or more) control flows are racing toward the same memory location, and whichever happens to arrive in which order determines — sometimes silently — what final value survives. Crucially, a race condition is not merely "two threads ran at the same time." It specifically means the result differs depending on execution order, when it should not. The subtlety is that operations which look atomic in source code are usually not atomic at the hardware level. Consider the innocuous statement `count = count + 1`. On a typical architecture this compiles to roughly: ``` LOAD R1, count ; read shared variable into a register ADD R1, R1, #1 ; increment in the register STORE count, R1 ; write back to shared memory ``` Each of these three machine instructions is a separate, individually indivisible step, but the statement as a whole is not indivisible. If two threads execute this sequence concurrently, the scheduler is free to interleave their instructions in many ways, and some interleavings produce a wrong answer relative to what the programmer intended (both increments being reflected). This leads to the precise definition used throughout operating systems: the critical section is the segment of a process's or thread's code where it accesses shared resources — variables, data structures, files, devices — whose concurrent, uncoordinated access can produce inconsistent results. Surrounding a critical section is typically an entry section (code that requests permission to enter) and an exit section (code that releases that permission), with the rest of the program called the remainder section. The critical section problem is the challenge of designing entry/exit protocols so that, no matter how the scheduler interleaves instructions across threads, at most one thread is ever executing inside its critical section at a time, and the system as a whole keeps making progress.

How it works

Step 1 — Identify the shared state and the non-atomic operation

The first step in reasoning about a race is always to find the shared variable(s) and to decompose the high-level operation into the actual machine-level steps a real CPU would execute (load, compute, store). This decomposition is what exposes the possible interleavings; reasoning at the level of source-code statements hides the danger.

Step 2 — Enumerate possible interleavings

With `n` threads each executing `k` atomic instructions in their critical-section-adjacent code, there are many possible legal interleavings — legal meaning each thread's own instructions still execute in its own program order. For two threads with `k` instructions each, the number of interleavings that preserve each thread's internal order is the number of ways to merge two sequences of length `k`, which is the binomial coefficient: ``` C(2k, k) = (2k)! / (k! * k!) ``` Even for small `k` this grows fast (k = 3 gives C(6,3) = 20 interleavings for just two threads doing three instructions each), which is exactly why "I tested it and it worked" is not proof of correctness — a bug may live in 1 interleaving out of thousands, waiting for the right scheduling moment (often under load, on specific hardware, or only in production).

Step 3 — Recognize the critical section

Once the offending shared-access code is identified, the operating-systems approach frames it as: this block is a critical section, and it must be guarded so that only one thread executes it at a time relative to any other thread's critical section touching the same data.

Step 4 — State the requirements any fix must satisfy

Any correct entry/exit protocol wrapped around a critical section must guarantee three properties simultaneously: 1. Mutual Exclusion — if a thread is executing in its critical section, no other thread can be executing in its critical section (for the same shared resource) at the same time. 2. Progress — if no thread is in its critical section, and some threads wish to enter, only threads not in their remainder section can participate in deciding who enters next, and that decision cannot be postponed indefinitely. 3. Bounded Waiting — there exists a bound on the number of times other threads are allowed to enter their critical sections after a thread has requested entry and before that request is granted. These three properties are the specification; the algorithms that satisfy them (covered separately) are the implementation. Stating them precisely is what allows a proposed fix to be proven correct rather than merely observed to work in testing. ```pseudocode // Generic structure that any solution to the critical section // problem must fit into. The entry/exit logic is what varies // across solutions (locks, Peterson's algorithm, semaphores...). shared data D // e.g., a bank balance, a counter, a buffer process P_i: loop forever: entry_section() // must eventually let P_i in (progress), // and never let two P_i's in together (mutual exclusion) critical_section() // touches D exit_section() // releases access, allows others to proceed remainder_section() // code unrelated to D ``` ```pseudocode // The race condition itself, illustrated at instruction granularity. // Two threads run this concurrently on a shared variable "count". shared int count = 0 Thread A: Thread B: r1 = count r2 = count r1 = r1 + 1 r2 = r2 + 1 count = r1 count = r2 // One interleaving that loses an update: // r1 = count (r1 = 0) // r2 = count (r2 = 0) <- B reads BEFORE A's write lands // r1 = r1 + 1 (r1 = 1) // count = r1 (count = 1) // r2 = r2 + 1 (r2 = 1) // count = r2 (count = 1) <- overwrites A's update // Expected count = 2, actual count = 1. ```

Figure
Two interleavings of `count++` on shared memory — one loses an update
Shared memory: count 0 Thread A timeline t1: r1 = count (0) t3: r1 = r1+1 (1) t5: count = r1 (→1) Thread B timeline t2: r2 = count (0) t4: r2 = r2+1 (1) t6: count = r2 (→1) B's read at t2 happens before A's write at t5, so A's +1 is lost. Final count = 1 (expected 2)

Complexity & correctness

Complexity of the problem itself. The critical section problem is not an algorithm with a running time; it is a specification. What has complexity is the space of possible interleavings, and this is the quantity that makes informal testing unreliable. For `n` threads each performing `k` atomic steps in the danger zone, the number of valid interleavings (respecting each thread's own program order) is the multinomial coefficient: ``` (n·k)! / (k!)^n ``` For n = 2, k = 3 this is 6!/(3!·3!) = 20. For n = 4, k = 3 it is already 12!/(6·6·6·6) = 369,600. This combinatorial blow-up is the formal reason why a race condition can hide for a long time in production: the fraction of interleavings that trigger the bug can be astronomically small, so random or lightly-loaded testing is very unlikely to sample it, while a heavily loaded server (which realizes far more interleavings per unit time and has more varied scheduling decisions) is far likelier to expose it.

Correctness reasoning — why "it usually works" is not proof. The right way to argue about concurrent correctness is to treat the program not as a single sequential trace but as a set of all legal interleavings of the individual threads' instruction sequences, and require the invariant to hold at the end of every member of that set. Formally, if thread `A` has instruction sequence `a1, a2, ..., ak` and thread `B` has `b1, ..., bm`, a schedule is any sequence that is a shuffle (interleaving preserving relative order within each thread) of the two sequences. A program is race-free with respect to some invariant `I` (e.g., "count equals the number of increments performed") if `I` holds after every possible shuffle, not just the one the programmer happened to observe. This is why the standard proof technique for showing a race condition exists is a witness interleaving: exhibit one specific interleaving under which the invariant fails (as done above with the lost-update trace). Conversely, to prove a fix is correct, one must argue about mutual exclusion, progress, and bounded waiting as invariants that hold under an adversarial scheduler — one that can pause any thread at any instruction boundary for an arbitrary (but finite) amount of time. That adversarial framing is deliberately pessimistic: a solution that is correct against an adversarial scheduler is correct against every real scheduler, because real schedulers are strictly less malicious (they must eventually run every ready thread, satisfying weak fairness in most systems).

Honesty about tradeoffs. Recognizing a race and stating the three critical-section requirements does not, by itself, give an efficient or even a satisfying fix — that is the subject of separate algorithms. Naively "fixing" races by disabling interrupts or busy-waiting can guarantee mutual exclusion while destroying performance or even violating progress (e.g., disabling interrupts across all cores is impractical on multiprocessors; naive busy-waiting protocols can starve a thread and fail bounded waiting). The critical section framing exists precisely so that these tradeoffs can be evaluated against a fixed, agreed-upon yardstick rather than by intuition.

Worked example

Trace the shared-counter race concretely with a table of interleaved instructions, then contrast it with an interleaving that happens to be safe — showing that the same code produces different results depending purely on timing. Initial state: `count = 0`. Two threads, A and B, each want to execute `count = count + 1`.

StepThreadInstructionRegister state`count` after
1A`r1 = count`r1 = 00
2A`r1 = r1 + 1`r1 = 10
3A`count = r1`r1 = 11
4B`r2 = count`r2 = 11
5B`r2 = r2 + 1`r2 = 21
6B`count = r2`r2 = 22

This "sequential" interleaving (A fully finishes before B starts) gives the correct answer, `count = 2`. Now consider a different, equally legal interleaving of the same two instruction streams:

StepThreadInstructionRegister state`count` after
1A`r1 = count`r1 = 00
2B`r2 = count`r2 = 00
3A`r1 = r1 + 1`r1 = 10
4B`r2 = r2 + 1`r2 = 10
5A`count = r1`r1 = 11
6B`count = r2`r2 = 11

Here both threads read `count` while it was still `0` (steps 1–2), before either write occurred. Both compute `1` locally and both write `1` back. The final value is `1`, even though two increments were performed — this is the classic lost update. No instruction executed incorrectly; each individual load, add, and store did exactly what it should. The bug is purely in the interleaving, which is exactly why race conditions are notoriously hard to reproduce with a debugger: single-stepping a thread in a debugger changes its timing and can make the race disappear ("Heisenbug" behavior). This worked example generalizes: with `n` concurrent increments and no protection, the final value of `count` can be any integer between `1` (all reads happen before all writes) and `n` (fully sequential), depending purely on scheduling — a striking demonstration that the correctness of concurrent code is a property of the set of possible schedules, not of any single execution path.

Common pitfalls & traps

PitfallWhy it happensConsequence
Assuming high-level statements are atomic`x++`, `x += 1`, list appends often compile to multiple machine instructionsLost updates, corrupted shared state
"It worked in testing" reasoningOnly a tiny fraction of interleavings get exercised under light loadRace manifests only in production under heavy load
Debugging makes the bug vanishBreakpoints/logging change thread timing, altering which interleaving occursFalse confidence that the bug is "fixed"
Confusing mutual exclusion with progressA solution can prevent all threads from entering forever and still exclude correctlyDeadlock or livelock disguised as "safety"
Ignoring bounded waitingA protocol may allow mutual exclusion but let one thread starve indefinitelyStarvation under adversarial but legal scheduling
Protecting the wrong scopeGuarding only part of the shared-data access, or guarding unrelated codeRace persists on the unguarded portion

Pioneers & history

The critical section problem was crystallized as concurrent computing itself became practical — first with multiprogrammed operating systems sharing a single CPU among processes, and later with genuinely parallel multiprocessors and multithreaded applications. The formal three-property specification (mutual exclusion, progress, bounded waiting) and the first constructive software solutions emerged from foundational work on process synchronization in the 1960s.

Origins · 1 minEdsger W. Dijkstra and how this was discovered
EW
Edsger W. Dijkstra
1930–2002 · Formalized mutual exclusion and coined foundational synchronization concepts

Dijkstra's 1965 paper on the mutual exclusion problem for cooperating sequential processes gave one of the first rigorous treatments of what would become the critical section problem, including an early multi-process solution. He later introduced the semaphore as a general synchronization primitive, directly building on this framing of shared-resource access as a problem requiring a provable protocol rather than ad hoc caution.

History
1965
— Dijkstra publishes a solution to mutual exclusion among n cooperating processes.
1966
— Dekker's earlier two-process mutual exclusion algorithm becomes widely known through Dijkstra's writing.
1981
— Gary Peterson publishes a simpler two-process mutual exclusion algorithm.
1965
— Dijkstra introduces the semaphore as a synchronization primitive built to satisfy critical-section requirements.
1990
s — Widespread multiprocessor and multithreaded programming make race-condition bugs a mainstream, everyday concern beyond OS kernels.

Where this matters

Every operating system kernel is riddled with critical sections: the scheduler's ready queue, the page-table and virtual memory structures, file system metadata, and device driver state are all shared data structures accessed by interrupt handlers and multiple CPU cores simultaneously. Kernel developers spend enormous effort identifying every such critical section and protecting it correctly, because a missed one can corrupt kernel state and crash the entire machine, not just one process. Beyond the kernel, race conditions are a daily concern in application-level concurrency: web servers handling simultaneous requests that touch a shared cache or connection pool, database engines coordinating concurrent transactions on the same rows, and multithreaded desktop or mobile applications updating shared UI state from background threads. Modern multicore hardware makes this unavoidable — even a "single-threaded-looking" language runtime typically has a garbage collector, JIT compiler, or I/O subsystem running concurrently with user code, each with its own critical sections. This problem statement is also the direct ancestor of every synchronization primitive taught afterward. Locks and Mutual Exclusion mechanisms, semaphores, monitors, and lock-free/atomic-instruction techniques are all engineering answers to the same three requirements defined here. Understanding races and the critical section precisely — rather than as a vague warning to "be careful with shared variables" — is what lets an engineer evaluate whether a proposed synchronization scheme is actually correct, or just usually lucky. It also connects directly to Requirements for a Correct Critical Section Solution, where mutual exclusion, progress, and bounded waiting are examined as a formal checklist against which real algorithms are verified.

Ask the tutor: Can you show me why Peterson's algorithm satisfies all three critical section requirements, step by step?

What exactly counts as a "critical section"?

A critical section is any segment of code where a thread or process accesses shared, mutable resources — variables, data structures, files, hardware registers — such that concurrent, uncoordinated access by another thread can leave that resource in an inconsistent state. It is defined relative to the shared resource, not to the whole program: a thread can have many critical sections, each guarding different data, and code that only touches thread-local variables is never part of a critical section regardless of how many threads run it concurrently.

Is a race condition the same thing as a data race?

They are closely related but not identical. A data race is a specific, low-level condition: two threads access the same memory location concurrently, at least one access is a write, and there is no synchronization ordering them. A race condition is the broader, semantic idea that program output depends on timing. Code can have a data race without an observable race condition (if the result happens to be harmless), and some race conditions arise from higher-level timing bugs (like two synchronized operations happening in the wrong order) without a raw data race at the memory level.

Why doesn't simply making `count++` a single CPU instruction eliminate all races?

Some architectures do offer atomic increment instructions for a single variable, and using one does eliminate the race for that specific operation. But real critical sections usually involve multiple related shared variables or a check-then-act sequence (e.g., "if buffer not full, then insert"), and no single atomic instruction can make an entire multi-step, multi-variable operation atomic. The critical section problem is about protecting arbitrarily complex code blocks, not just single-variable updates, which is why general-purpose entry/exit protocols are needed.

Can race conditions happen with a single CPU core?

Yes. Even on one core, preemptive scheduling can suspend a thread mid-instruction-sequence (for example, after the load but before the store in an increment) and run another thread that touches the same variable. The single core creates the illusion of one thing happening at a time, but at the instruction-interleaving level it behaves just like true parallelism. This is why the critical section problem predates multicore hardware — it was fully relevant to time-shared single-processor operating systems in the 1960s.

Why is bounded waiting listed separately from progress? Doesn't progress already prevent starvation?

Progress only guarantees that some waiting thread eventually gets to decide and enter — it does not say which thread. A protocol could satisfy progress by always favoring one particular thread whenever there is a tie, letting a different thread starve forever even though the system as a whole keeps making progress. Bounded waiting closes this gap by requiring a finite bound on how many times other threads can cut in line ahead of any thread that is already waiting.

Does using more CPU cores make race conditions more likely, or just more visible?

Both, in a real sense. More cores mean more genuinely simultaneous instruction execution, which increases the raw number of interleavings that can occur per unit of wall-clock time, especially for critical sections whose duration is comparable to inter-core communication delays. At the same time, races that were already present due to preemption on a single core become easier to trigger and reproduce because there are more opportunities for the offending interleaving to occur, which is why bugs "appear" when code is ported to more parallel hardware even though the underlying flaw was there all along.

If race conditions are so dangerous, why not just make every operation atomic by default?

Making every operation atomic (for example, wrapping the whole program in one giant lock) does prevent races, but it destroys the entire point of using multiple threads: it serializes execution and forfeits parallelism, often making the program slower than a single-threaded version due to lock overhead. The critical section problem exists precisely to find the smallest, most precisely scoped regions that need protection, and to satisfy mutual exclusion, progress, and bounded waiting there — while leaving everything else free to run concurrently for actual performance gains.

Where this leads