Shared State & Races

Reasoning About Races: A Method, Not an Instinct

A race condition is correctness that depends on timing. Finding one is not a matter of staring harder — it is a repeatable procedure: list the shared accesses, put a task switch after each, and ask what the other task could do in that gap. This lesson turns the check-then-act shape into a drill you can run on any diff.

▶ Run the lab

The question this answers

The question

Given a function I did not write, how do I systematically find the schedule that breaks it?

The work

A promo-code redemption handler: if (promo.remaining > 0) { promo.remaining -= 1; grantDiscount(user) }, run by every checkout request against a promo limited to 100 uses.

What is shared

promo.remaining, an integer in a shared map of active promotions. grantDiscount writes to the user's record — a different location, and a second shared access most reviewers do not count.

The invariant — what must stay true under every interleaving

The number of granted discounts never exceeds 100: grantsIssued + promo.remaining === 100 at every instant, and promo.remaining >= 0.

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 shape: check, gap, act

Almost every race condition a service engineer meets is one shape wearing different clothes. Something is observed, a decision is made from that observation, and the decision is acted on — with a gap in between during which the observation may stop being true. if (balance >= 100) withdraw(100). if (!exists(path)) create(path). if (!cache.has(k)) cache.set(k, expensive()). if (promo.remaining > 0) promo.remaining -= 1. The operating-systems literature calls it time-of-check to time-of-use; in application code it is usually just called "the bug".

The critical realisation is that the check produces a *fact about the past*. promo.remaining > 0 was true at the instant of the read and carries no promise about the instant of the write. Everything downstream of the check is acting on history. The bug is not that the check is wrong; it is that the code treats a historical fact as a current one.

Two things widen the gap dramatically and are worth spotting on sight. An await between the check and the act turns a nanosecond window into a network round trip. And a check performed in one service against a decision acted on in another turns it into a distributed problem where no local primitive helps at all. The version below has both problems in one function.

1async function redeem(promoId: string, userId: string) {
2 const promo = promos.get(promoId) // (1) shared read
3 if (promo.remaining <= 0) return { ok: false, reason: 'exhausted' }
4
5 // <-- GAP 1: another task may decrement to 0 here
6
7 const user = await users.load(userId) // (2) ~15 ms await. The gap is now
8 // wide enough for thousands of tasks.
9 if (user.hasUsedPromo(promoId)) return { ok: false, reason: 'already-used' }
10
11 // <-- GAP 2: another task may redeem for this same user
12
13 promo.remaining -= 1 // (3) shared read-modify-write
14 await grants.insert({ userId, promoId }) // (4) shared write, different location
15 return { ok: true }
16}
17
18// Three questions to run on this function, in order:
19// Q1 Which lines touch state that appears in the invariant? -> 1, 3, 4
20// Q2 For each, what could another task do immediately after? -> see below
21// Q3 Does any later line assume something an earlier line read? -> yes: line 3
22// assumes line 1's reading of remaining > 0 is still true.
23//
24// The invariant mentions grantsIssued and promo.remaining. Line 3 and line 4
25// update those two facts non-atomically, across an await. There is therefore a
26// schedule in which remaining is decremented and the grant never lands, and a
27// schedule in which the grant lands twice.
Three shared accesses, two gaps, one obvious-looking function

The drill: put a switch after every shared access

Here is the procedure, and it is deliberately mechanical so that it works when you are tired and reviewing someone else's diff at 18:00. Write the function's shared accesses as a numbered list — reads and writes of state that appears in the invariant, nothing else. Then, for each position *between* consecutive accesses, insert a hypothetical task switch and ask one question: if a second copy of this function ran to completion right here, would the invariant still hold when we resume?

That question is answerable without cleverness. It has a yes or a no, and the no comes with the failing schedule already written. The schedule below is the answer for gap 1 in redeem, played out with promo.remaining at 1 — the last redemption — and two tasks in flight.

Two refinements make the drill sharper. First, the second copy does not have to be the *same* function; check for any other writer of the same state, because those are the ones nobody thinks of. Second, remember that a switch can also occur *inside* what looks like one access: promo.remaining -= 1 is itself read-modify-write, so position it as two entries in the list, not one. That is Interleavings: The Schedule Is Part of the Program applied recursively.

Last promo code, two checkouts in flight. Both pass the check.ILLUSTRATIVE
Invariant · grantsIssued + promo.remaining === 100, and promo.remaining >= 0
#Checkout A — user 700Checkout B — user 701State
1read promo.remaining → 1·remaining=1 grants=99
2check 1 > 0 → true·remaining=1 grants=99
3await users.load(700) — task suspends·remaining=1 grants=99
4·read promo.remaining → 1remaining=1 grants=99
5·check 1 > 0 → trueremaining=1 grants=99
✕ Two tasks have now both been told there is one code left. The invariant is doomed here, several milliseconds before any write.
6·await users.load(701); resume; write remaining = 0remaining=0 grants=99
7·await grants.insert(701)remaining=0 grants=100
8resume; write remaining = 1 - 1·remaining=0 grants=100
9await grants.insert(700)·remaining=0 grants=101
✕ 101 grants issued against a 100-use promo, and remaining is 0 so the counter looks correct. The overspend is invisible in the counter and visible only in the grants table.
The promo counter reads exactly 0 and the grants table has 101 rows. Note the specific cruelty: the counter — the thing you would monitor — is *correct*. Detection requires comparing two sources. Under real load this does not overspend by one; it overspends by roughly the number of requests in flight during the last few milliseconds of the promo, which is why these incidents are always reported as "we gave away 4,000 extra discounts", never one.

Running the drill on a diff

The output of the drill should be written down, because "I thought about it" is not reviewable. The annotated listing below is what a completed pass looks like: every shared access numbered, every gap examined, every gap given a verdict, and the fix attached to the gap it closes rather than to the function as a whole.

Notice the last line of the analysis. Two of the three gaps in redeem cannot be closed with a process-local lock at all, because the state lives in the database and there is more than one instance of this service running. That is a genuinely common outcome and it is why the drill is valuable: it tells you not just *that* there is a race but *where the arbiter has to live*. See A Mutex on Server A Does Nothing About Server B and The Database Solves Concurrency For Its Data, Not For Your Memory.

redeem(promoId, userId)          invariant: grantsIssued + remaining === 100
                                                    and remaining >= 0

  shared accesses (only state named in the invariant):
    S1  read   promo.remaining              line 2
    S2  read   promo.remaining              line 9   (the RMW's read half)
    S3  write  promo.remaining              line 9   (the RMW's write half)
    S4  write  grants                       line 10

  gaps, and what a second task can do in each:

    S1 -> S2   width: one await (~15 ms)
               second task can: pass its own check, decrement, insert a grant
               verdict: RACE. Both tasks act on remaining > 0 read before either wrote.
               evidence: schedule above. Overspend scales with in-flight requests.

    S2 -> S3   width: nanoseconds (no await between them)
               second task can: complete its own read-modify-write
               verdict: RACE. Classic lost update; see [[interleavings]].
               note: narrow enough to pass every test and still fire in production.

    S3 -> S4   width: one await (~8 ms)
               second task can: nothing that breaks the invariant...
               ...but a CRASH here does: remaining is decremented, no grant issued.
               verdict: NOT a race, but a partial-failure hole. A code is burned
               and nobody receives it. Needs the two writes in one transaction.

  fixes, attached to the gap each one closes:

    S1->S2, S2->S3   move the decision into the store, atomically:
                     UPDATE promos SET remaining = remaining - 1
                      WHERE id = ? AND remaining > 0
                     -- then check affected rows. 0 rows means exhausted.
                     Closes both gaps; works across service instances.

    S3->S4           put the decrement and the grant insert in one transaction.
                     Closes the partial-failure hole. Does NOT close S1->S2.

    NOT a fix        a process-local mutex around lines 2-10. It closes the gaps
                     for one instance and closes nothing for the other five
                     replicas, and it now holds a lock across two awaits.
                     See [[lock-scope]], [[local-lock-not-distributed]].
A completed race-analysis pass, as it would appear in a review comment

Key points

  • A race condition is logical correctness that depends on timing. It is a property of the *design*, not of the memory model — see Data Race Is Not Race Condition for the other thing that word gets used for.
  • Nearly all of them are check-then-act: a fact is read, a decision is made from it, and the fact is allowed to change before the decision is applied.
  • The check produces a fact about the past. Treating it as a fact about the present is the bug, stated in one sentence.
  • The method: number the shared accesses named in the invariant, insert a hypothetical switch in every gap, and ask whether a complete second execution there would break the invariant.
  • Count read-modify-write as two accesses. Half the races hide inside what looks like a single statement.
  • An await in a gap widens it by six orders of magnitude. Look at those gaps first.
  • The drill also tells you *where* the fix must live — if the state is in a database and the service has replicas, no in-process primitive can close the gap.

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
  • State the invariant, and list only the shared locations it mentions — this bounds the analysis and keeps it finishable.
  • Number every read and every write of those locations in source order, splitting read-modify-write into its two halves.
  • For each gap between consecutive accesses, hypothesise a task switch and run a complete second execution of any writer of that state.
  • Evaluate the invariant on resume. If it can be false, you have the failing schedule and should write it down as evidence.
  • Attach each candidate fix to the specific gap it closes, and check that it closes that gap for every instance of the program, not just this one.
Interleavings that matter
  • A checks remaining=1; A awaits; B checks remaining=1; B decrements and grants; A resumes, decrements to 0 and grants. 101 grants against a 100-use promo, with the counter reading a tidy 0.
  • A checks; A decrements; A grants; B checks remaining=0 and rejects. Correct — and it is the only schedule a single-request test can produce.
  • A decrements to 0; A crashes before inserting the grant. Not a race — a partial failure. The code is consumed and never delivered, which needs a transaction, not a lock.
  • Both tasks pass user.hasUsedPromo before either grant lands, so one user receives the same promo twice. A second race in the same function, on different state, found by the same drill.
  • With UPDATE ... WHERE remaining > 0: A's update affects 1 row, B's affects 0. B rejects. No interleaving breaks the invariant, because the check and the act are one statement executed by one arbiter.
What it guarantees — and does not
  • The drill guarantees you have examined every gap you listed. It does not guarantee you listed every shared access — state reached through a cache, a closure or a library callback is routinely missed.
  • A local mutex guarantees mutual exclusion within one process. It guarantees nothing when a second replica of the service is running, which is the normal deployment.
  • A conditional UPDATE ... WHERE guarantees atomicity of check-and-act at the row, under the database's isolation level — and guarantees nothing about a second, related row unless both are in one transaction.
  • Passing tests guarantee that the schedules the harness produced were correct. Under a scheduler you do not control, that is a statement about a sample, not about the program.
Where contention appears
  • The drill itself costs nothing at runtime; the fixes do. Moving the decision into the database converts an in-process race into a row-level lock and therefore into database contention on a single hot row. See The Database Solves Concurrency For Its Data, Not For Your Memory, and the hot-row contention it creates.
  • A promo counter is a single hot row by construction — every checkout touches it — so the last minutes of a popular promo are a contention event as well as a correctness one.
  • The local-mutex non-fix has the worst contention profile of all the options: it serialises the handler across two awaits while still being incorrect across replicas.
How it fails
  • Overspend — more grants issued than the limit allows, scaling with in-flight concurrency rather than by one.
  • Lost update on the counter, hidden by arithmetic: two tasks write the same value from different stale reads, so the counter looks right.
  • Duplicate grant to one user, from the second check-then-act in the same function.
  • Partial failure between the decrement and the grant — not a race, but found by the same pass and equally invisible.
  • False confidence: a local lock is added, the code review passes, and the bug survives because the service runs six replicas.
When it helps
  • On every diff that touches shared state — the pass takes two minutes on a small function and is the only review technique that reliably finds this class.
  • During incident analysis, where the drill turns "it must be a race somewhere" into a specific gap with a specific schedule you can show people.
  • Before choosing a primitive: knowing which gaps must close tells you whether you need a lock, a transaction, an atomic, or a redesign.
When it hurts
  • When applied to state that is not actually shared — the analysis is real work and produces nothing.
  • When it turns into exhaustive enumeration for more than two or three actors. Past that, use a race detector or a model checker; hand analysis stops being reliable. See Race Detectors: What They Find, and What They Structurally Cannot.
  • When it produces a defensive lock at every gap. Some gaps are benign — a stale read used only for a metric does not need closing, and closing it costs contention forever.
How you would know
  • Compare two independently derived counts: promo.remaining against SELECT count(*) FROM grants WHERE promo = ?. Divergence is the direct evidence.
  • Alert on the invariant, not the counter. The counter was correct in the failing schedule above; the comparison was not.
  • Reproduce deliberately: fire N concurrent redemptions against a promo with 1 remaining and assert exactly one succeeds. Repeat under an artificial delay inserted at each gap. See Stress Testing: A Test That Passed Once Proves Nothing.
  • Check the affected-row count of every conditional update. Code that issues UPDATE ... WHERE remaining > 0 and ignores the row count has implemented the fix and thrown away its result.
Complexity it introduces
  • The analysis is cheap; the fixes are not. Moving the arbiter into the database couples the handler to transaction semantics and isolation levels the team must now understand.
  • Every gap closed with a lock adds an acquisition order and a hold-time budget to the module's contract.
  • Recording the analysis — which gaps were examined and which were judged benign — is extra documentation that nothing enforces, and its absence is why the same race is re-introduced two quarters later.
Simpler alternatives
  • Make the check and the act one statement executed by an arbiter: a conditional UPDATE ... WHERE, an atomic putIfAbsent, an INCR with a bound. No gap means no analysis. See The Database Solves Concurrency For Its Data, Not For Your Memory.
  • Make the operation idempotent and let it run twice safely — a unique constraint on (userId, promoId) turns the duplicate-grant race into a caught constraint violation. See Optimistic Concurrency Control.
  • Serialise the decision onto one owner: a single task, partition or worker that owns this promo. Removes the interleaving instead of reasoning about it. See The Actor Model.
  • Accept the race where the invariant is soft. A promo that may overspend by a fraction of a percent under burst may be a business decision, not a bug — but that must be a decision someone made, not an accident.

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.

Two increments, twenty schedules: find the one that loses an update

Two increments, twenty schedules
Both tasks run counter++ on the same variable. Drive the schedule yourself: read, add, write are three separate steps, and the scheduler may cut between any two of them.
6/6 steps
counter
2
increments completed
2
rA / rB
1 / 2
invariant
holds
Invariant · after k completed increments, counter === k. No update is lost.
#Task A — counter++Task B — counter++State
1rA ← counter·counter=0 rA=0 rB=0
2rA ← rA + 1·counter=0 rA=1 rB=0
3counter ← rA·counter=1 rA=1 rB=0
4·rB ← countercounter=1 rA=1 rB=1
5·rB ← rB + 1counter=1 rA=1 rB=2
6·counter ← rBcounter=2 rA=1 rB=2
counter = 2, and both callers are right. This schedule happens to be safe because one task finished entirely before the other started. Safe once is not safe: press "Enumerate all" to see how many of the possible schedules do not. Testing samples this space; it does not cover it.
SIMPLIFIEDcounter++ modelled as three indivisible steps. Real compilers and CPUs can split it further, or fuse it into one atomic instruction.

Race detector lab

Race detector: what it catches and what it cannot
The left panel is a detector — it looks at accesses and locks. The right panel enumerates every schedule and checks the invariant. They do not always agree, and that disagreement is the lesson.
Scenario
Two tasks run count = count + 1 with nothing around it.
Detector · accesses observed
taskaccesslocationholding
Areadcount
Awritecount
Breadcount
Bwritecount
DATA RACE A.readB.write — read/write on count from different tasks with no common lock
DATA RACE A.writeB.read — write/read on count from different tasks with no common lock
DATA RACE A.writeB.write — write/write on count from different tasks with no common lock
Enumerator · 6 schedules explored
Invariant · count equals the number of increments that have completed
#Task ATask BState
1r1 = count·count=0 done=0
2·r2 = countcount=0 done=0
3count = r1 + 1·count=1 done=1
4·count = r2 + 1count=1 done=2
✕ count equals the number of increments that have completed — broken here
Shown: the first schedule that breaks the invariant. Every step is a legal execution — no compiler trick, no exotic hardware, just an ordering the scheduler is allowed to pick.
3 unsynchronized conflicting pairs on count, at least one of them a write. That is a data race by definition, and in C++ it is undefined behaviour rather than a wrong number. 4 of 6 schedules also break the invariant, so this one is a race condition too.
Both a data race and a race condition, which is why this example teaches so badly on its own: it lets people believe the two words mean the same thing.
Real detectors (ThreadSanitizer, Helgrind, Go’s -race) work on happens-before edges observed at runtime, so they only report races on code paths that actually executed, and they slow the program enough to change its timing. This model shows the reasoning, not their output.
3 data races4/6 schedules break the invariantSIMULATED

What people believe, and what is true

Claim

It is a race condition only if two threads are involved.

Reality

Two *tasks* are enough. A single-threaded event loop interleaves at every await, and the resulting race is identical in kind and wider in window.

Claim

I checked it right before using it, so it is fine.

Reality

"Right before" is the entire problem. The check yields a fact about the past; any gap at all, however short, is a gap.

Claim

Adding a mutex fixed it — the test passes now.

Reality

It fixed it for one process. If the state lives in a database and the service has replicas, the mutex closes the gap on one replica and leaves it open on the others.

Claim

The counter is correct, so we did not overspend.

Reality

In the schedule above the counter is exactly 0 and 101 grants were issued. Detecting this class requires comparing two independently derived values.

Go deeper

Overview

A race condition is a bug whose presence depends on timing. Almost all of them are: check something, then act on it, with a gap in between.

Practical

Number the shared accesses, examine each gap, ask whether a full second execution there breaks the invariant. Write the answer down and attach a fix to each gap that fails.

Advanced

Split read-modify-write into two accesses, count writers you did not write, and check whether the fix works for every instance of the program. Judge benign gaps explicitly rather than closing them by reflex — a defensive lock in a benign gap is permanent contention bought for nothing.

Internals

Hand enumeration is exponential in the number of actors and accesses, which is why the tooling exists: dynamic race detectors instrument accesses and track a happens-before relation (Happens-Before: The Edge That Makes a Write Visible), while model checkers explore the schedule space exhaustively for small programs. Neither finds a logical race whose accesses are all properly synchronized — the double-booking in Finding the Critical Section is invisible to both.

Apply it