CMD Guide
HomeConcurrencyConcurrency Foundations

Critical Section and Race Condition

A critical section is any span of code that reads and then writes shared state, relying on the value it read still being true at the moment it writes — and a race condition is what happens when a second thread slips in between that read and that write, so the first thread's write is computed from a value that is already stale. The hazard is not the sharing itself; it is the gap between observe and update, during which an invariant the code assumes ("nobody else has touched this yet") quietly becomes false.

The mechanism: one line of source is three machine steps

The reason a critical section can be "entered" by two threads at once is that the statement you wrote is not what the CPU executes. A hardware core cannot add to a memory location in a single indivisible step; it must pull the value into a register, change the register, and push it back. So balance += 1 compiles to a read-modify-write triple:

  1. LOAD — copy balance from memory into a register
  2. ADD — add 1 to the register (memory is untouched)
  3. STORE — write the register back to balance

The OS scheduler (or another core) can interrupt the thread between any two of these. The window from LOAD to STORE is the critical section. While one thread sits inside it, the invariant it is counting on — "the value I LOADed is still the current value" — is true only until some other thread STOREs. If that happens, the first thread's later STORE writes a number computed from a value that no longer exists in memory. That overwrite is called a lost update.

A worked example: four threads, +1 each, with real register values

Start with a shared cell balance = 20. Four threads each run balance += 1 once. If their critical sections ran one-at-a-time the answer is 24. Here is one concrete interleaving that produces 21 — each row shows what each thread holds in its private register and what is actually in memory.

StepThreadOperationr1r2r3r4balance (memory)
1T1LOAD r1 ← balance2020
2T2LOAD r2 ← balance202020
3T3LOAD r3 ← balance20202020
4T4LOAD r4 ← balance2020202020
5T1ADD r1 = 20+12120202020
6T2ADD r2 = 20+12121202020
7T3ADD r3 = 20+12121212020
8T4ADD r4 = 20+12121212120
9T1STORE balance ← r12121212121
10T2STORE balance ← r22121212121 (T1's write lost)
11T3STORE balance ← r32121212121 (T2's write lost)
12T4STORE balance ← r42121212121 (T3's write lost)

All four LOADed 20 before anyone STOREd, so all four computed 21, and the four STOREs all wrote the same 21 — three increments vanished. This is the worst-case interleaving; in practice you usually get some increments through (say 22 or 23), which is exactly what makes the bug so dangerous: it is non-deterministic and often almost right. Note the result depends only on scheduling, not on the arithmetic — this is why a race is defined by the ordering of accesses affecting the outcome, with at least one access being a write.

diagram
diagram

The fix: make the critical section indivisible

Correctness needs three guarantees on the critical section. A lock provides mutual exclusion (at most one thread inside at a time) and progress (if nobody holds the lock, some waiter gets in). Bounded waiting — no thread waits forever — is only guaranteed by a fair lock such as ReentrantLock(true); the default synchronized monitor or an unfair ReentrantLock may allow a freshly arriving thread to barge ahead of a parked waiter and, under extreme contention, starve it. Acquiring a lock forces the LOAD, ADD, and STORE to happen as one uninterruptible unit and publishes the new value before the next thread reads — so each thread reads the latest value and the four increments cannot collapse onto each other.

Java — synchronized guards the critical section

class Account {
    private long balance = 20;
    private final Object lock = new Object();

    // The lock makes LOAD-ADD-STORE one atomic, visible step.
    void deposit() {
        synchronized (lock) {   // enter critical section
            balance += 1;       // read-modify-write, now indivisible
        }                       // exit + publish (happens-before next acquire)
    }
    long get() {
        synchronized (lock) { return balance; }
    }
}

// Driver: 4 threads each deposit once -> always 24
Account a = new Account();
Thread[] ts = new Thread[4];
for (int i = 0; i < 4; i++) { ts[i] = new Thread(a::deposit); ts[i].start(); }
for (Thread t : ts) t.join();
System.out.println(a.get());   // 24, every run

Why the naive version is wrong: drop the synchronized and write a bare balance += 1 on a plain long field, and you reproduce the table above — the field has no atomicity (the three steps interleave) and no visibility guarantee (a thread may keep reading a cached stale value indefinitely). The output drifts below 24 and varies run to run.

Go — a sync.Mutex does the same job

package main

import (
	"fmt"
	"sync"
)

type Account struct {
	mu      sync.Mutex
	balance int64
}

func (a *Account) Deposit() {
	a.mu.Lock()         // enter critical section
	a.balance += 1      // read-modify-write, now indivisible
	a.mu.Unlock()       // exit + publish
}

func main() {
	a := &Account{balance: 20}
	var wg sync.WaitGroup
	for i := 0; i < 4; i++ {
		wg.Add(1)
		go func() { defer wg.Done(); a.Deposit() }()
	}
	wg.Wait()
	fmt.Println(a.balance) // 24, every run
}

How the two runtimes differ: Java's Thread maps to an OS thread the kernel schedules; a Go goroutine is a cheap user-space task multiplexed by the Go runtime onto a small pool of OS threads (the M:N scheduler), so spawning thousands is normal. But the data hazard is identical — a goroutine can be preempted mid read-modify-write just like a thread. Go's idiomatic alternative is to not share the cell at all: hand it to one owner goroutine and have others send deltas over a channel ("share memory by communicating"), which removes the critical section instead of guarding it. Java's nearest analogue is structuring work so only one thread touches the state, or using wait/notify on a monitor; Go has no wait/notify — it uses channels and sync.Cond for that role. Run either unguarded version under go run -race / a Java race detector and the tool will flag the unsynchronized access directly.

Pitfalls

Takeaways

🎯 Drill Ladder — survive the follow-ups

L0 · A critical section is the read-modify-write gap where a stale assumption becomes a race; the fix must make the whole gap indivisible, not just visible.

L1 · ① Concurrency — "Two threads both read balance=20, both compute 21. You mark the field volatile. Does that fix it?"
Trap: "Yes — volatile makes reads and writes consistent across threads, so this is now safe."
Bar: volatile only guarantees visibility and ordering for a single read or a single write of that field (a happens-before edge) — it does nothing to the LOAD-ADD-STORE triple, which is still three separate operations a second thread can interleave inside. You need an operation that collapses those three steps into one indivisible step: a lock, or a hardware compare-and-swap. connects-to: the Java Memory Model page (visibility vs. atomicity)

L2 · ② Failure — "The thread holding the lock crashes mid-critical-section. What happens to everyone waiting, and is the data okay?"
Trap: "The lock auto-releases when the thread dies, so a waiter just gets let in cleanly."
Bar: An intrinsic synchronized monitor is released automatically on abrupt completion — the JVM emits an implicit monitorexit in the exception table — but that only frees the lock, not the invariant: if the crash happened between two related writes, the data is left half-updated even though the lock looks clean. An explicit Lock has no such guarantee — skip try/finally and the lock is held forever; a distributed lock fails differently again, since the holder's death is invisible until a lease/TTL expires, and a woken "zombie" holder needs a fencing token or it can still write after its lease is gone. connects-to: distributed locking, leases & fencing tokens

L3 · ③ Scale — "1M req/s hit one counter behind a single mutex, hot key. Where does the throughput actually go?"
Trap: "The critical section is a single instruction, so it barely costs anything — throughput scales with cores."
Bar: A single mutex serializes every access regardless of core count, and under real contention the per-acquire cost is dominated not by the increment but by the cache-coherence round trip — each acquire pulls the lock's cache line into Modified state on one core (MESI), invalidating every other core's copy, so throughput collapses toward that ping-pong latency, not the CPU's raw op rate. The fix is to shard the hot cell (per-core striped counters that combine on read) so writes stop contending on one cache line at all. connects-to: cache coherence, MESI & false sharing

L4 · ⑤ Adversary/Edge — "Your CAS retry loop looks lock-free and safe. Can a hostile scheduler starve a thread forever, and separately, what's the ABA trap here?"
Trap: "It's lock-free, so no thread ever blocks — every thread makes progress every retry."
Bar: Lock-free only guarantees system-wide progress, not per-thread progress — an adversarial scheduler can let faster threads keep winning the compare-and-swap race while one thread retries indefinitely (livelock for that thread). Separately, if the value cycles A→B→A between a thread's LOAD and its CAS, the CAS succeeds even though the structure underneath changed — fixed only by packing a version tag alongside the value, never by anything inside the critical section itself. connects-to: compare-and-swap & the ABA problem

L5 · ⑥ Cost/Simplicity — "Why not just wrap everything in one global lock — simplest possible mental model?"
Trap: "Correctness first — one global lock is obviously safe, optimize later if profiling shows contention."
Bar: A single global lock turns every touch of that state into a de-facto single-threaded program, so Amdahl's law caps throughput immediately regardless of core count — and it doesn't stay simple: the moment a second lock is added for another subsystem, every code path now has a lock-acquisition order to reason about, which is exactly how deadlocks get born. The right cost trade is granularity matched to measured contention — coarse locks on cold paths, sharding or lock-free structures only where profiling actually shows a hot lock. connects-to: deadlock, livelock & starvation

The floor keeps dropping: beyond L5 the staff+ question isn't "how do I guard this critical section" but "should this shared, mutable cell exist at all" — idempotency keys, CRDTs, or single-writer partitioning that make the race structurally impossible instead of merely well-guarded.

Self-locate: died at L1 → mid-level; L4+ → staff signal.

Facing any new concept? Hit it with the six: concurrent? failing? at 100×? over time? adversarial? worth the cost? — that's the interviewer's whole playbook.


Re-authored and deepened for this guide. Sources: Silberschatz, Galvin & Gagne, Operating System Concepts (critical-section problem; mutual exclusion, progress, bounded waiting); Brian Goetz et al., Java Concurrency in Practice (read-modify-write, atomicity and visibility, intrinsic locks); Donovan & Kernighan, The Go Programming Language, ch. 9, and Effective Go ("share memory by communicating", sync.Mutex, the race detector). The full register-level two-thread counter++ interleaving is traced on the next page, “Race Conditions — A Traced Interleaving of counter++”.

🤖 Don't fully get this? Learn it with Claude

Stuck on Critical Section and Race Condition? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Critical Section and Race Condition** (Concurrency) and want to truly understand it. Explain Critical Section and Race Condition from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Critical Section and Race Condition** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Critical Section and Race Condition** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Critical Section and Race Condition** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes