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:
- LOAD — copy
balancefrom memory into a register - ADD — add 1 to the register (memory is untouched)
- 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.
| Step | Thread | Operation | r1 | r2 | r3 | r4 | balance (memory) |
|---|---|---|---|---|---|---|---|
| 1 | T1 | LOAD r1 ← balance | 20 | – | – | – | 20 |
| 2 | T2 | LOAD r2 ← balance | 20 | 20 | – | – | 20 |
| 3 | T3 | LOAD r3 ← balance | 20 | 20 | 20 | – | 20 |
| 4 | T4 | LOAD r4 ← balance | 20 | 20 | 20 | 20 | 20 |
| 5 | T1 | ADD r1 = 20+1 | 21 | 20 | 20 | 20 | 20 |
| 6 | T2 | ADD r2 = 20+1 | 21 | 21 | 20 | 20 | 20 |
| 7 | T3 | ADD r3 = 20+1 | 21 | 21 | 21 | 20 | 20 |
| 8 | T4 | ADD r4 = 20+1 | 21 | 21 | 21 | 21 | 20 |
| 9 | T1 | STORE balance ← r1 | 21 | 21 | 21 | 21 | 21 |
| 10 | T2 | STORE balance ← r2 | 21 | 21 | 21 | 21 | 21 (T1's write lost) |
| 11 | T3 | STORE balance ← r3 | 21 | 21 | 21 | 21 | 21 (T2's write lost) |
| 12 | T4 | STORE balance ← r4 | 21 | 21 | 21 | 21 | 21 (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.
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 runWhy 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
- "It passed on my machine" is not evidence. A race often only loses an update under load, on more cores, or with a different scheduler. Passing once means nothing; you need a stress loop or a race detector.
- Guarding the write but not the read. If one thread reads
balancewithout the lock while another writes under it, you still get a torn or stale read. Every access in the race — reads included — must use the same lock. - Different locks for the same data. Two methods that each synchronize on a different object provide zero mutual exclusion against each other. The lock identity, not the keyword, is what serializes.
- Assuming a single statement is atomic.
x++,x += y, lazy-init (if (f == null) f = ...), and even some 64-bit reads are multi-step. "One line" says nothing about indivisibility. - Holding the lock too long. The correctness fix has a cost: a critical section that does I/O or sleeps while holding the lock serializes everything and can tank throughput or deadlock. Keep critical sections short and CPU-only.
Takeaways
- A critical section is the read-to-write window where a thread assumes the value it read is still current; a race is another thread invalidating that assumption mid-window.
- The mechanism is real hardware: one source line is LOAD–ADD–STORE, and the scheduler can interrupt between any two — so increments computed from the same stale read collapse into one (a lost update).
- The fix needs mutual exclusion, progress, and bounded waiting; a lock gives all three by making the section indivisible and publishing the result before the next reader.
- Java threads are OS threads; Go goroutines are user-space tasks on an M:N scheduler — the hazard is identical, but Go often dissolves the critical section entirely by passing data through channels rather than guarding shared memory.
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.
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.
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.
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.
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.