Shared State & Races

Finding the Critical Section

Operating Systems defines what a critical section is. The engineering skill is finding the *minimal* one: the smallest region that must be indivisible for the invariant to hold. Too wide and you serialise work that did not need serialising; too narrow and the invariant breaks while every access is dutifully locked.

▶ Run the lab

The question this answers

The question

What is the smallest region of this function that must be protected, and how do I know I have not made it one statement too small?

The work

A reserveSeat(showId, seatId) handler: look up the show, verify the seat is free, fetch the user's loyalty tier over the network, compute a price, mark the seat taken, and append an audit row.

What is shared

show.seats — a map from seat id to null or a booking id — held in memory and reachable from every request task. The loyalty lookup and the audit sink are not shared state; they are I/O.

The invariant — what must stay true under every interleaving

A seat is reserved by at most one booking: for every seat id, seats[seatId] transitions from null to a booking id exactly once, and no two bookings observe it as null.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The two failure directions are not symmetric

Getting the region wrong fails in two directions, and they cost completely different things. Too wide costs throughput: correctness is preserved, the system is simply slower than it needed to be, and the damage is proportional to how much unrelated work you dragged inside. Too narrow costs correctness: the invariant breaks, silently, on a schedule your tests do not produce.

That asymmetry is worth stating plainly because it dictates the order of operations. Start wide — put the lock around the whole operation, convince yourself it is correct — and then shrink it deliberately, checking at each step that the invariant still cannot be observed false. Shrinking is an optimisation, and like every optimisation it should be measured (What Contention Actually Costs) rather than assumed. Starting narrow and widening on failure means shipping the failure.

The reserveSeat handler below has an obvious wide answer and a much better narrow one, and the difference between them is a network call. The loyalty lookup takes 40 ms and touches nothing shared. Holding the seat lock across it converts a microsecond-scale critical section into a 40-millisecond one, which is a 40,000× increase in the window during which every other task wanting any seat in this show is blocked.

Too wide — correct, and serialises a network round trip
1reserveSeat(showId, seatId, userId):
2 with show.lock: # <-- acquired here
3 show = shows[showId]
4 if show.seats[seatId] != null:
5 return SEAT_TAKEN
6 tier = loyaltyService.get(userId) # 40 ms of network. Touches nothing shared.
7 price = priceFor(show, seatId, tier) # pure computation
8 booking = Booking(userId, price)
9 show.seats[seatId] = booking.id
10 auditSink.append(booking) # another 5 ms of I/O
11 return booking # <-- released here
12
13# Correct: no interleaving can break the invariant.
14# Cost: the lock is held for ~45 ms per reservation. Every other task
15# reserving ANY seat in this show waits behind it, so peak throughput for
16# the whole show is ~22 reservations/second regardless of core count.
Minimal — the region is exactly the check-then-act on the shared map
1reserveSeat(showId, seatId, userId):
2 show = shows[showId] # read of an immutable registry entry
3 tier = loyaltyService.get(userId) # outside. 40 ms, shared nothing.
4 price = priceFor(show, seatId, tier) # outside. pure.
5 booking = Booking(userId, price) # outside. object is task-local until published.
6
7 with show.lock: # <-- region begins
8 if show.seats[seatId] != null: # check
9 return SEAT_TAKEN
10 show.seats[seatId] = booking.id # act
11 # <-- region ends
12 auditSink.append(booking) # outside. I/O, and not part of the invariant.
13 return booking
14
15# The region is now two operations on one map entry: microseconds.
16# Note what moved and why: the price computation moved out because it
17# touches nothing shared; the audit append moved out because the audit
18# invariant is "every successful booking is eventually audited", which is
19# an at-least-once obligation, not a mutual-exclusion one.

Both versions preserve the invariant. The narrow one preserves it while holding the lock for roughly one ten-thousandth as long, because the only statements that must be indivisible are the ones that read and write the shared map. Everything else — the network call, the pricing, the object construction, the audit — either touches nothing shared or is governed by a different, weaker obligation.

One statement too narrow

Now the other direction, which is where the real bugs live. A reviewer looking at the minimal version above might reasonably suggest shrinking further: the check is a read, the assignment is a write, and each could have its own lock. Or, more commonly and more subtly, the two get separated by a refactor — isSeatFree() becomes a helper with its own lock, takeSeat() becomes another, and both are "synchronized".

The schedule below is what that produces. Every single access to show.seats happens under the lock. A race detector finds nothing, because there is no unsynchronized access. And two bookings hold the same seat, because mutual exclusion on each half of a check-then-act guarantees nothing about the pair.

The rule this yields: the region must span from the first read the decision depends on to the last write that makes the decision true. Any lock boundary drawn inside that span is a lock that runs, costs, and prevents nothing. This is also why "make the data structure thread-safe" is rarely the answer — a thread-safe map makes each operation atomic and leaves every check-then-act over it broken.

Both halves individually locked. Every access synchronized. Still wrong.ILLUSTRATIVE
Invariant · seats[H12] transitions null → booking exactly once; no two bookings observe it null
#Task A — reserve seat H12 for user 900Task B — reserve seat H12 for user 901State
1acquire lock·seats[H12]=null lock=A
2read seats[H12] → null·seats[H12]=null lock=A
3release lock·seats[H12]=null lock=free
4·acquire lockseats[H12]=null lock=B
5·read seats[H12] → nullseats[H12]=null lock=B
✕ Two bookings have now both observed the seat as free. The invariant is already doomed; nothing has been written yet.
6·release lockseats[H12]=null lock=free
7acquire lock; write seats[H12] = bk-900; release·seats[H12]=bk-900 lock=free
8·acquire lock; write seats[H12] = bk-901; releaseseats[H12]=bk-901 lock=free
✕ The seat now belongs to booking 901; booking 900 was confirmed to user 900 and no longer exists in the map. Two tickets, one seat, no error.
9return 200 { seat: H12, booking: bk-900 }·seats[H12]=bk-901
Every read and every write of the shared map was performed under the lock, so a data-race detector reports a clean run. The invariant broke anyway, because the region was drawn around each *access* rather than around the *decision*. Two customers arrive at the cinema with tickets for seat H12.

The method: three boundary questions

Finding the minimal region is mechanical once the invariant is named (Invariants: Name It Before You Lock It). Walk the function statement by statement and ask three questions in order. The answers give you the boundary directly, and they are the same three questions regardless of language or primitive.

One extra rule matters more than the rest in production code: an I/O call inside the region is almost always a mistake, and an I/O call whose latency you do not control is always one. A lock held across a network request has a hold time set by someone else's p99, which means your throughput ceiling is set by a system you do not operate. This is common enough to be its own lesson — see Lock Scope: What You Hold It Across — and it is also the mechanism behind most lock convoys (Lock Convoys).

The matrix below applies the three questions to reserveSeat line by line. Note that the answer for the audit append is "outside", not because it is unimportant but because its obligation is *at-least-once delivery*, a different invariant with a different mechanism — a retry or an outbox, not a mutex.

StatementQ1: touches state in the invariant?Q2: does a later decision depend on it?Q3: is it slow or externally controlled?Verdict
show = shows[showId]no — the registry entry is immutable after startupnonoOUTSIDE
tier = loyaltyService.get(userId)nono — price does not affect who gets the seatyes — 40 ms, third-party p99OUTSIDE (and would be even if it were fast)
price = priceFor(...)no — pure function of local valuesnonoOUTSIDE
if seats[seatId] != nullYES — reads the invariant's stateYES — the write below is conditional on itnoIN — this is where the region starts
seats[seatId] = booking.idYES — writes the invariant's staten/a — this is the decisionnoIN — this is where the region ends
auditSink.append(booking)no — different state, different invariantnoyes — I/OOUTSIDE; its own at-least-once obligation
Applying the three questions to `reserveSeat`. IN = must be inside the region.

Key points

  • The critical section is the span from the first read a decision depends on to the last write that makes the decision true. Not the set of accesses — the span.
  • Too wide costs throughput and stays correct. Too narrow costs correctness and stays fast. Start wide, shrink deliberately, verify at each step.
  • A check-then-act split across two separately locked regions is fully synchronized and fully broken — race detectors will report nothing.
  • "Thread-safe data structure" makes each operation atomic and leaves every check-then-act over it racy. It is a component, not a solution.
  • Anything pure, task-local, or governed by a different invariant belongs outside the region — including the object you are about to publish, as long as nothing else can reach it yet.
  • I/O inside the region hands your throughput ceiling to whoever owns that I/O's latency.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • Name the invariant and list the shared locations it mentions. Everything else in the function is a candidate for exclusion.
  • Find the earliest statement whose value a later shared write depends on. The region starts there — not at the write.
  • Find the last statement that restores the invariant. The region ends there — not at the end of the function.
  • Move everything between those bounds that touches neither the invariant's state nor the decision *out*: pure computation, allocation, logging, I/O, and object construction that has not yet been published.
  • Verify the shrink: for each statement moved out, ask what an interleaving at that point could observe. If the answer is "a value nobody else can reach", the move is safe.
Interleavings that matter
  • Wide region: A holds the lock through the loyalty call; B waits 40 ms and then observes H12 taken. Correct, slow.
  • Minimal region: A holds the lock for the check and the write; B blocks for microseconds and observes H12 taken. Correct, fast.
  • Region split in two: A checks (null), releases; B checks (null), releases; A writes bk-900; B writes bk-901. Two bookings, one seat, every access locked.
  • Region too narrow the other way — only the write is locked: A reads null unsynchronized, B reads null unsynchronized, both write under the lock. Same double-booking, plus an unsynchronized read that is now also a data race in C++.
  • No region at all, but the map is a "concurrent map": every individual get and put is atomic; the check-then-act between them is not. Identical failure.
What it guarantees — and does not
  • A correctly drawn region guarantees that no other participant observes the state while the invariant is false. That is the entire guarantee.
  • It does not guarantee ordering between tasks — which of two waiting tasks gets the seat is a fairness question the primitive may or may not answer. See Fairness.
  • It does not guarantee anything about state outside the region, including the object you constructed before entering, once that object becomes reachable.
  • It guarantees nothing across process boundaries. The same region on two servers protects two different maps; see A Mutex on Server A Does Nothing About Server B.
Where contention appears
  • Contention is proportional to hold time × arrival rate. Halving the region halves the wait for everyone behind it, which is why shrinking is worth real effort on a hot path.
  • The wide version pins the whole show to ~22 reservations per second because 45 ms of hold time admits no more, no matter how many cores are available.
  • Granularity is the other axis: one lock per show rather than one per cinema turns a global bottleneck into per-show bottlenecks. One lock per *seat* goes further and adds a lock-ordering obligation if a booking ever spans two seats. See Lock Ordering.
  • Measure hold time and wait time separately — hold time is what you shrink, wait time is what users feel. See Hold Time, Wait Time, and the Ratio Between Them.
How it fails
  • Double booking — the check-then-act split across regions. Two winners, no error.
  • Lock convoy — a wide region under load produces a queue that never drains, so latency grows without bound while CPU sits idle. See Lock Convoys.
  • Deadlock introduced by shrinking — splitting one lock into two finer ones creates an ordering obligation that did not exist before. See Lock Ordering.
  • Blocked event loop — the wide version in a single-threaded runtime does not merely slow this handler down, it stalls every unrelated task in the process. See Blocking the Event Loop.
  • Silent regression — a later commit moves one statement inside the region "for safety" and quietly restores the 40 ms hold time.
When it helps
  • On any hot shared structure: shrinking the region is the highest-yield contention fix available and does not change the primitive or the API.
  • When a lock is held across I/O — moving the call out is usually a small refactor with a large, measurable effect on p99.
  • When adding a feature to an already-locked function; the three questions tell you immediately whether the new statement belongs inside.
When it hurts
  • When shrinking crosses the invariant boundary. A region that is one statement too small is worse than one that is ten too large.
  • When shrinking requires splitting one lock into several and the resulting ordering obligation is not written down. You traded contention for deadlock.
  • When the region is already microseconds and the contention is elsewhere. Shrinking a cold lock is effort spent on a number nobody measures.
  • When the "shrink" is achieved by moving a write outside and hoping — the classic way a correct region becomes an incorrect one during a refactor.
How you would know
  • Lock hold time distribution, not the mean. A p99 hold time three orders of magnitude above p50 usually means an I/O call slipped inside on one path.
  • Lock wait time at p99, and the count of tasks waiting — the pair distinguishes "held too long" from "acquired too often". See Hold Time, Wait Time, and the Ratio Between Them.
  • Throughput ceiling per protected resource: if reservations per second for a show plateaus at roughly 1/hold-time, the region is your bottleneck.
  • An assertion at the region's exit that recomputes the invariant, enabled in staging. It catches a boundary drawn one statement too narrow far more reliably than a load test does.
Complexity it introduces
  • A minimal region is harder to read than a wide one: the reasoning for why each excluded statement is safe outside lives in the author's head unless it is written down.
  • Every shrink is a claim that must be re-verified when the function changes, and function changes do not carry a reminder.
  • Finer granularity multiplies lock objects, which multiplies the lock-ordering surface and the deadlock surface.
  • Constructing an object outside the region and publishing it inside adds a safe-publication obligation in languages with a relaxed memory model. See Safe Publication: Handing Over a Finished Object.
Simpler alternatives
  • Push the whole decision into a system that arbitrates atomically: UPDATE seats SET booking = ? WHERE show = ? AND seat = ? AND booking IS NULL and check the affected row count. No region, no lock, and it works across servers. See The Database Solves Concurrency For Its Data, Not For Your Memory.
  • Use a data structure whose API *is* the region: an atomic putIfAbsent / set-if-null is a check-then-act with no window, which is the entire reason those methods exist.
  • Give each show to a single owner task and send reservations as messages — one actor, no interleaving, at the cost of a per-show serialisation point. See The Actor Model.
  • Optimistic control: read a version, compute freely, write conditionally on the version being unchanged, retry on conflict. Better when conflicts are rare. See Optimistic vs Pessimistic.

How much of the task is inside the lock?

How much of the task is inside the lock?
One 5 ms task on 8 cores. Slide the fraction of it that has to run inside the critical section and watch the parallelism the machine can actually deliver.
throughput1,000/s · 1.00 ms locked · 4.00 ms parallel
effective parallelism5 · 8 cores available · ceiling for this lock scope is 5.0
lock busy
90.0%
lock wait
9.0 ms
cores idle
37.5%
1 workerdashed = linear speedup16 workers · max 16.0×
Effective parallelism as workers are added, at the current lock scope. The dashed line is what more workers would buy if nothing were serialised.
20% of each task holds the lock, so 20% of the work is serialised no matter how many cores you own. Effective parallelism is 5.00 of 8 — the ceiling is 100/20 = 5.0× and no hardware purchase moves it. This is Amdahl's law arriving through a lock rather than through an algorithm. The move is to shrink the region, not to hold it more cleverly: compute outside the lock, take it only to publish; or split the state so tasks contend on different locks. Both cost complexity — the lock you can delete is always cheaper than the lock you optimise.
SIMULATEDA model of a single global lock. Real locks add acquisition cost, cache-line traffic and unfairness on top of this.

A mutex buys correctness with throughput

A mutex buys correctness with throughput
The same counter, unlocked and locked. Left column: what the schedules do. Right column: what the lock costs. Both are always on screen because you never get to choose only one.
4 cores · 4 ms CPU per task
No lock18/20 schedules lose an update
correct schedules2 · 20 possible interleavings of the two tasks
throughput
952/s
effective parallelism
3.81
Mutex around the incrementalways 2
correct schedules2 · 2 possible interleavings of the two tasks
throughput
500/s
effective parallelism
2.00
The lock removes every failing schedule — not by making them unlikely, but by making them unreachable: with the read-modify-write inside one critical section there are only 2 schedules left and neither loses an update. It costs 47.5% of throughput (952/s → 500/s) and drops effective parallelism from 3.8 to 2.00 on 4 cores. At 2 ms the region is small relative to the 4 ms of work, so most of the task still runs in parallel. This is what "small critical section" buys — and it is the only knob here that is free. What the mutex does not give you: ordering between the tasks, fairness, or protection for any other variable. It protects the region you put it around, and nothing else.
SIMULATEDSIMPLIFIEDSchedule counts are exact for this model; throughput comes from the lab model, not a measurement.

if (balance >= 100) withdraw(100) — drive it until it overdraws

if (balance >= 100) withdraw(100)
Two withdrawals of 100 from an account holding 100. The check and the debit are separate operations; you decide who runs when.
balance = 100

withdraw(amount):        # both tasks run this concurrently
    b = read(balance)    # 1
    if b >= amount:      # 2  <- decided on a value that may already be stale
        debit(amount)    # 3
0 schedules tried
balance
0
paid out
100
A decided
withdraw
B decided
Invariant · balance >= 0 — the account is never overdrawn.
#Withdrawal A (100)Withdrawal B (100)State
1rA ← read balance·balance=100 paidOut=0
2if rA >= 100·balance=100 paidOut=0
3debit 100·balance=0 paidOut=100
Balance is 0 and nothing has broken yet. Watch for the shape: both tasks passing step 2 before either reaches step 3. That is check-then-act, and the check is only as good as the instant it was made.
SIMPLIFIEDThe debit itself is modelled as atomic. The bug is the gap between the check and the act — not the arithmetic.

What people believe, and what is true

Claim

Every access is inside a lock, so it is safe.

Reality

Mutual exclusion per access says nothing about a sequence of accesses. The double-booking schedule above has 100% locked access and 0% correctness.

Claim

A bigger lock is always safer.

Reality

Wider is safer for *this* invariant and strictly worse for throughput, and it is how deadlocks are born: a region wide enough to contain a second lock acquisition is a region wide enough to contain a cycle.

Claim

Using a concurrent hash map removes the need for a critical section.

Reality

It removes the need for one around each individual operation. if absent then put still needs one, unless you use the structure's own atomic putIfAbsent.

Apply it