Shared State & Races

Invariants: Name It Before You Lock It

Synchronization exists to preserve invariants, and nothing else. "Add a mutex" is not a design decision until you can finish the sentence "so that ___ is never observed to be false". This lesson is about writing that sentence first, because it determines the primitive, the region and the test.

▶ Run the lab

The question this answers

The question

What must remain true under every possible interleaving — and how does naming it decide which primitive you need?

The work

A withdrawal handler and a bounded work queue in the same service: account.withdraw(amount) on a balance, and queue.push(job) against a queue with capacity 500.

What is shared

account.balance and account.ledger (a two-field invariant), and queue.items with queue.size (a size-versus-capacity invariant). Both are reachable from every request-handling task.

The invariant — what must stay true under every interleaving

Three, stated concretely: balance >= 0 and balance === opening - sum(ledger); queue.items.length <= 500; and *one owner per job* — no job is handed to two workers.

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 invariant decides the primitive, not the other way round

Engineers reach for a mutex the way they reach for a try/catch: as a general-purpose safety gesture. The result is code that is locked and still wrong, because the lock protects a *variable* while the invariant spans two, or protects one method while the invariant spans a sequence of three calls made by the caller.

Naming the invariant first changes the shape of the answer. "balance >= 0" tells you the region is check-plus-deduct, not just deduct. "items.length <= 500" tells you the region is check-plus-push, and also that a *counting semaphore* with 500 permits expresses it more directly than a mutex does (Semaphores: Counting Permits as a Resource Limit). "One owner per job" tells you the invariant is not about a number at all but about a set partition, which usually wants a claim operation that is atomic against itself — a conditional update, not a lock (Optimistic Concurrency Control).

A good invariant is falsifiable. You should be able to write a function check() that returns a boolean, run it against the live state, and get an answer. "The data is consistent" fails that test and is therefore not an invariant; it is a wish. "balance === opening - sum(ledger.withdrawals)" passes, and it is also — not by coincidence — exactly the reconciliation job you want in production.

InvariantFalsifiable checkRegion it impliesPrimitive it suggests
balance >= 0balance >= 0 after every operationread balance → compare → deduct, as one unitMutex over the check-then-act pair, or a conditional UPDATE ... WHERE balance >= ?
balance === opening − sum(ledger)recompute from the ledger and compareboth writes — the balance field and the ledger append — as one unitMutex spanning both fields, or a transaction if the state is in a database
queue.items.length <= 500length <= 500 at any observation pointread length → compare → push, as one unitCounting semaphore with 500 permits, which encodes the bound in the primitive itself
one owner per jobno job id appears in two workers' in-flight setsselect an unclaimed job → mark it claimed, as one unitConditional claim: UPDATE jobs SET owner=? WHERE id=? AND owner IS NULL and check the row count
Four real invariants, and what each one implies about the mechanism

Watching `balance >= 0` die

The withdrawal below is written by a careful engineer. It checks the balance before deducting. It is still wrong, and the schedule shows why: the check and the deduction are two separate observations of the shared value, and between them the world changed. Task B's check was truthful when it ran and false by the time B acted on it.

This is where the phrase "protect the invariant, not the variable" earns its keep. Both balance reads are correct reads. Both balance writes are correct writes. If you wrapped only the write in a lock, every step would be individually synchronized and the account would still go negative — the lock would be doing real work and preventing nothing. The region that must be indivisible is precisely the span across which the invariant is allowed to be temporarily false, which here is *check through deduct*.

Two withdrawals of 100 from a balance of 150. Both pass their check.ILLUSTRATIVE
Invariant · balance >= 0 at every observable instant
#Task A — withdraw(100)Task B — withdraw(100)State
1read balance (150)·balance=150
2check 150 >= 100 → true·balance=150
3·read balance (150)balance=150
4·check 150 >= 100 → truebalance=150
5write balance = 150 - 100·balance=50
6append ledger: -100·balance=50 ledger=[-100]
7·write balance = 150 - 100balance=50 ledger=[-100]
✕ B computed from its own stale read of 150, so it also wrote 50 — the second invariant, balance === opening − sum(ledger), is now false: 50 !== 150 − 100 − 100.
8·append ledger: -100balance=50 ledger=[-100, -100]
✕ The ledger says 200 was withdrawn from 150. balance >= 0 held throughout, and the account is still 50 short.
Two things worth separating. The balance >= 0 invariant was never violated at any observable instant — a monitor watching only for a negative balance would report nothing. The *second* invariant, that balance reconciles against the ledger, is violated by 100. This is why you name every invariant: the one you monitored held, and the one you did not was the one that broke.

Invariants that span more than one variable

Single-variable invariants are the easy case and the rare one. balance >= 0 is about one field, and an atomic compare-and-subtract could enforce it alone. But the moment a second field must agree with the first — a ledger, a count cached alongside a list, an index alongside the collection it indexes, a size alongside items — no atomic type helps, because atomicity is a property of a single location and the invariant is a property of a relationship.

That is the practical rule to take away: the number of variables in the invariant sets the floor on the mechanism. One variable and one operation, an atomic will do. One variable but check-then-act, you need a CAS loop or a lock. Two or more variables, you need a lock, a transaction, or a design where the two facts are one value — for example storing the list and its size in a single immutable record that is swapped by one reference write (Safe Publication: Handing Over a Finished Object).

The enqueue below shows the two-variable version and the one-value fix side by side, in pseudocode so that the structure rather than any language's syntax is what is visible.

1# --- the two-variable invariant: size must always equal items.length, and both <= 500
2
3enqueue(job):
4 if size >= 500: # read #1 (shared)
5 return REJECTED
6 items.append(job) # write #1 (shared)
7 size = size + 1 # write #2 (shared) <- invariant is false between #1 and #2
8
9# Failing schedule: A reads size 499 -> B reads size 499 -> A appends -> B appends
10# -> A sets size 500 -> B sets size 500
11# items.length is 501, size says 500. Both the capacity bound and the
12# size-equals-length invariant are now false, and nothing errored.
13
14# --- fix 1: make the region indivisible. The lock's scope is exactly the
15# span across which the invariant is allowed to be false.
16
17enqueue(job):
18 with lock: # region = check + both writes. Not less, not more.
19 if size >= 500: return REJECTED
20 items.append(job)
21 size = size + 1
22
23# --- fix 2: remove the second variable. One value, one write, no window.
24# Readers see either the old snapshot or the new one, never a mix.
25
26enqueue(job):
27 loop:
28 old = state # one reference read
29 if old.items.length >= 500: return REJECTED
30 new = Snapshot(old.items + [job]) # size is derived, never stored
31 if compare_and_swap(state, old, new): return ACCEPTED
32 # else: someone else swapped first; our snapshot is stale, retry
33
34# fix 2 costs an allocation per enqueue and unbounded retries under heavy
35# contention. It buys lock-free reads and one fewer invariant to maintain.
36# See [[optimistic-concurrency-control]] and [[copy-on-write-sharing]].
Two variables that must agree, and the restructuring that makes them one

Key points

  • Synchronization has exactly one purpose: preventing an invariant from being observed false. If you cannot name the invariant, you cannot evaluate the lock.
  • A real invariant is falsifiable — you can write check() and run it. "The data is consistent" is not an invariant.
  • The invariant determines the region: the critical section is the span across which the invariant is temporarily false.
  • The number of variables in the invariant sets the floor on the mechanism: one variable and one op → atomic; one variable, check-then-act → CAS or lock; two or more → lock, transaction, or a single-value redesign.
  • The invariant you monitor is not necessarily the one that breaks. Enumerate all of them — the balance-non-negative check passed while the ledger reconciliation failed.
  • Some invariants are better expressed by a different primitive entirely: a capacity bound is a semaphore, an ownership claim is a conditional update.

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
  • Write the invariant as a boolean expression over the shared state, using only fields you could actually read at runtime.
  • Identify every code path that can make it false — these are the writers, and the set is usually larger than expected.
  • For each writer, mark the first statement after which the invariant is false and the statement at which it is true again. That span is the candidate critical section.
  • For each reader, ask whether it can execute inside any writer's span. If it can, the reader is part of the protected region too.
  • Choose the smallest mechanism that makes the span indivisible with respect to every other participant — and re-derive it if the invariant later grows a variable.
Interleavings that matter
  • A checks 150 >= 100; B checks 150 >= 100; A writes 50; B writes 50. balance >= 0 never fails, and the ledger invariant fails by 100.
  • A checks; A writes 50; B checks 50 >= 100 → false; B rejects. Correct, and the schedule that every test produces.
  • Enqueue: A reads size 499; B reads size 499; both append; both set size = 500. items.length is 501 and size is 500 — two invariants broken by one window.
  • With the region locked: A holds the lock through check-append-increment; B blocks at the check and observes size 500, rejecting correctly. No interleaving breaks either invariant.
  • With the snapshot design: A and B both read the same old; one CAS succeeds and the other observes a changed reference, retries, sees 500 and rejects. No interleaving breaks it, at the cost of a retry.
What it guarantees — and does not
  • Naming an invariant guarantees nothing by itself — it is a design act, not a mechanism. What it guarantees is that the mechanism you pick can be evaluated instead of assumed.
  • A lock over the correct region guarantees the invariant holds at every point outside the region. It does not guarantee it holds *inside* — it guarantees nobody can look.
  • An atomic type guarantees indivisibility of one location. It explicitly does not guarantee that two atomic variables agree with each other at any instant.
  • A database transaction guarantees the invariant across the rows it touches, under the configured isolation level, and guarantees nothing about your process-local cache of the same data. See The Database Solves Concurrency For Its Data, Not For Your Memory.
Where contention appears
  • The invariant sets the minimum size of the critical section, and therefore the floor on contention. A wide invariant is expensive to hold regardless of which primitive you pick.
  • Splitting one wide invariant into several narrow ones — per-account balances rather than one global ledger lock — is the single most effective contention reduction available, and it is a modelling change, not a tuning change.
  • An invariant spanning an I/O call is the pathological case: the region cannot be shrunk without changing the design, so the lock is held for a network round trip. See Lock Scope: What You Hold It Across.
How it fails
  • Broken invariant with no error — the defining failure. Balance and ledger disagree; nothing throws.
  • Locked but still wrong — the lock protects a single write while the invariant spans a check-then-act, so every access is synchronized and the outcome is unchanged.
  • Monitored the wrong invariant — the alert watches balance >= 0, which held, while balance === opening − sum(ledger) silently failed.
  • Invariant drift — a new field is added to the structure and nobody updates the invariant or the lock's scope, so the region is now too narrow by one write.
  • Lost update on the check-then-act pair, which is the Interleavings: The Schedule Is Part of the Program failure viewed through the invariant lens.
When it helps
  • Always, before choosing a primitive — the discipline costs a sentence and rules out entire categories of wrong answer.
  • In code review, where "what invariant does this lock protect?" is the highest-yield question you can ask about a concurrency diff.
  • When designing a monitor: a falsifiable invariant is already a reconciliation query, so the design work doubles as observability work.
  • When deciding whether concurrency is worth it at all — an invariant spanning six variables and two services is a signal to keep the operation sequential.
When it hurts
  • When the invariant is stated so broadly that the implied region is the entire request. That is not an invariant, it is a refusal to analyse, and it produces a global lock.
  • When it is used to justify locking state that no second task can reach. Invariants over task-local data need no enforcement.
  • When the invariant genuinely belongs to the database and is re-implemented in application memory, producing two sources of truth that disagree under partial failure.
How you would know
  • Write the invariant as a query and run it on a schedule: recompute the balance from the ledger, compare size against len(items), count jobs with two owners. Mismatches per hour is the metric.
  • Assert the invariant in debug builds at the boundaries of every critical section — cheap, and it catches a region that is one statement too narrow.
  • Count invariants per lock. A lock protecting five unrelated invariants is a lock that will be held too long; a single invariant protected by three different locks is a bug waiting for a schedule.
  • Track how often the reconciliation job corrects something. Zero forever is the goal; a non-zero rate that scales with traffic is the signature of a too-narrow region.
Complexity it introduces
  • Each named invariant becomes a documented obligation on every future writer, and most languages provide no way to attach it to the data, so it lives in a comment and decays.
  • Multi-variable invariants force a lock or transaction where a single atomic would have done, adding a synchronization object, a lock-ordering obligation and a deadlock surface.
  • The single-value redesign removes an invariant but adds allocation, a retry loop and a staleness window that readers must be told about.
  • Every invariant you enforce in application memory is an invariant the database also thinks it owns; keeping the two definitions in step is ongoing work.
Simpler alternatives
  • Let the database own the invariant: a CHECK constraint, a unique index, or a conditional UPDATE ... WHERE is enforced once, correctly, for every writer including the ones you did not write. See The Database Solves Concurrency For Its Data, Not For Your Memory.
  • Remove the invariant by removing the second variable — derive size from items instead of storing it, and there is nothing left to keep in agreement.
  • Give the invariant a single owner task and send it messages; an invariant touched by exactly one actor cannot be broken by a schedule. See The Actor Model.
  • Express the invariant in the primitive: a capacity bound is exactly what a counting semaphore is for, and using one removes the hand-written check entirely. See Semaphores: Counting Permits as a Resource Limit.

The lost update, step by step

The lost update, step by step
One fixed schedule of two concurrent increments. Nothing to choose — watch where the invariant dies, and where the cause actually was.
1/6 · A · rA ← counter
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=—
2·rB ← countercounter=0 rA=0 rB=0
3rA ← rA + 1·counter=0 rA=1 rB=0
4counter ← rA·counter=1 rA=1 rB=0
5·rB ← rB + 1counter=1 rA=1 rB=1
6·counter ← rBcounter=1 rA=1 rB=1
✕ 2 increments completed, counter = 1
step
1 of 6
counter
0
increments completed
0
invariant
holds
A reads 0. Correct at this instant, and about to stop being correct. A read-modify-write is a window, not an instant. It stays open from the read to the write.
SIMPLIFIEDOne of twenty possible interleavings of this program, chosen because it fails.

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

The lock makes it thread-safe.

Reality

A lock makes a *region* mutually exclusive. Whether that produces correctness depends entirely on whether the region matches the invariant's span. A lock around the wrong region is fully functional and fully useless.

Claim

Each method is synchronized, so the class is safe.

Reality

Per-method locking protects each call and nothing about a sequence of calls. if (!map.containsKey(k)) map.put(k, v) on a fully synchronized map is still a race, because the invariant spans both calls.

Claim

If I make every field atomic, the object is consistent.

Reality

Atomicity is per-location. Two atomic fields can be read at an instant where one has been updated and the other has not — which is exactly the balance-and-ledger failure above.

Apply it