Shared State & Races

Shared Mutable State

The whole difficulty of concurrency compresses into one sentence: two tasks reach the same state, and at least one of them changes it. Remove either half — the reaching or the changing — and every lesson after this one becomes unnecessary.

▶ Run the lab

The question this answers

The question

Two tasks can both reach the same state — when does that create a coordination requirement, and when does it create none at all?

The work

Two HTTP handlers, A and B, running concurrently in one process, both adding a line item to the cart cached in memory for user 4471.

What is shared

One Cart object on the heap, reachable from both handlers through a process-wide Map<userId, Cart>. Its items array and its total number are both mutable, and either handler can read or write either field at any instant.

The invariant — what must stay true under every interleaving

cart.total equals the sum of item.price over cart.items — at every instant at which either handler could observe the cart, not merely at the end of each handler.

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?

Reachability plus mutation is the entire condition

A coordination requirement exists when two conditions hold at once. First, *reachability*: two tasks hold a path to the same memory — the same object, the same row, the same file descriptor, the same key in a shared map. Second, *mutation*: at least one of them writes. Both halves are load-bearing. Two tasks writing to two different objects need nothing from you. A thousand tasks reading one frozen configuration object need nothing from you either.

The trap is that reachability is transitive and mostly invisible. Handler A never wrote cart in its own source; it called getCart(userId), which consulted a module-level cache, which returned a reference. Nothing in A's code says "shared". The sharing lives in the *shape of the reference graph*, not in the syntax of the function, which is why "is this shared?" is a question about how the object was obtained rather than about how it is used.

So the first move on any concurrency question is mechanical: draw the path from each task to the state. If the two paths terminate at the same node and one of the arrows is a write, you have work to do. If they terminate at different nodes, or every arrow is a read, you are done — and "you are done" is a real, common, correct answer that engineers routinely talk themselves out of.

  • Shared + immutable: no coordination. A frozen config object read by 500 tasks needs nothing.
  • Unshared + mutable: no coordination. A local accumulator inside one task needs nothing, however heavily it is written.
  • Shared + mutable + all readers: still no coordination, but this is the state one refactor away from breaking. See Safe Publication: Handing Over a Finished Object.
  • Shared + mutable + at least one writer: this is the only quadrant the rest of the domain is about.
The reference graph is where the sharing lives — not in the handler source
getCart(4471)getCart(4471)same referencereadreadHandler A (request 8801)Handler B (request 8802)module-level Map<userId, Cart>PRICING (frozen) read-onlyCart #4471 items[] · total
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Shared immutable state is not shared, for our purposes

The engineering leverage in this lesson is the second row of the table below. If nothing writes, no interleaving can be wrong, because every schedule observes the same bytes. That is not a weaker guarantee than a mutex gives — it is a stronger one, because it holds without a lock, without a wait, without a deadlock risk and without a maintainer remembering to acquire anything.

This is why "make it immutable" is a legitimate first answer to a concurrency problem rather than a dodge. A request handler that builds a *new* cart and swaps the map entry atomically has converted a coordination problem into a single-word publication problem. The cost is real and should be stated: allocation per update, and readers that may hold a stale snapshot after the swap. Whether stale-but-consistent is acceptable is a product question, not a concurrency question — see Immutability as a Concurrency Strategy and Copy or Share?.

Note also the asymmetry hidden in "at least one writes". One writer and a thousand readers is still the dangerous quadrant. Readers do not need to conflict with each other to be broken; they need only to observe the writer mid-update, which is exactly what Interleavings: The Schedule Is Part of the Program is about.

Reachable by 2+ tasks?Anything writes?Coordination needed?What to do
NoYesNoneTask-local state. The cheapest correct answer; prefer it whenever the work can be phrased as "compute a value and return it".
YesNoNoneFrozen config, interned constants, a snapshot handed out by value. Document that it is frozen so nobody adds a setter.
YesYes — one writer, many readersYesReaders can observe a half-finished update. Publish a new immutable value, or use a read/write lock. See Read/Write Locks, Honestly.
YesYes — several writersYesThe full problem. Name the invariant, find the minimal region, then choose a primitive. See Invariants: Name It Before You Lock It, Finding the Critical Section.
The only quadrant that generates work — and what to reach for in each

Where the requirement actually shows up

The schedule below is deliberately mundane: neither handler does anything exotic, and both are correct in isolation. The invariant dies because items and total are two fields updated by two separate statements, and a task switch between them is legal. There is no line of code you can point at and call wrong.

Notice what the fix is *not*. Making push atomic would not help; making total = ... atomic would not help either. Both individual operations already complete without interruption. The thing that must be indivisible is the *pair*, because the invariant relates the two fields — which is the first appearance of the rule that dominates the rest of this module: you protect an invariant, never a variable.

Two adds to one cart. Both handlers are individually correct.ILLUSTRATIVE
Invariant · cart.total === sum of prices in cart.items
#Handler A — add "Lamp" (30)Handler B — add "Rug" (50)State
1read cart.items (length 1, total 20)·items=[Pen 20] total=20
2items.push(Lamp 30)·items=[Pen 20, Lamp 30] total=20
3·read cart.total (20)items=[Pen 20, Lamp 30] total=20
✕ B has read a total that does not match items; it read 20 while items already sum to 50
4·items.push(Rug 50)items=[Pen 20, Lamp 30, Rug 50] total=20
5write cart.total = 20 + 30·items=[Pen 20, Lamp 30, Rug 50] total=50
6·write cart.total = 20 + 50items=[Pen 20, Lamp 30, Rug 50] total=70
✕ items sum to 100; total reads 70. The Lamp is in the basket and free.
7respond 200 { total: 50 }·total=70
The cart contains 100 of goods and charges 70. Neither handler errored, neither logged anything, both returned 200, and the row that finally reaches billing is silently wrong. This class of bug is found by reconciliation, not by tests.

Key points

  • A coordination requirement needs two things at once: two tasks reach the same state, and at least one of them writes. Break either and the requirement disappears.
  • Sharing is a property of the reference graph, not of the calling code — a handler that never mentions sharing can still be handed a shared reference by a cache three frames down.
  • Shared *immutable* state needs no synchronization at all, and that is a stronger guarantee than a lock, not a weaker one.
  • One writer and many readers is still the dangerous quadrant; readers break by observing a half-finished update.
  • You protect an invariant that spans fields, never a single field. Making each field's write atomic fixes nothing when the invariant relates two of them.

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
  • Each task obtains a reference to the state, usually indirectly — a cache lookup, a singleton, a closure capture, a module-level variable, a connection handed out by a pool.
  • The runtime is free to switch between tasks at any point the language permits: a preemption on a thread, an await on a task, a bytecode boundary in an interpreter.
  • A multi-statement update passes through intermediate states in which the invariant is false — this is normal and unavoidable.
  • If another task reads or writes during one of those intermediate states, it observes or overwrites a value that was never meant to be visible.
  • The damage is a wrong value, not a crash, which is why it survives all the way to the database.
Interleavings that matter
  • A reads items; A pushes Lamp; A writes total — B never runs in between. Invariant holds. This is the schedule your tests produce.
  • A pushes Lamp; B reads total (stale, 20); B pushes Rug; A writes total = 50; B writes total = 70. Items sum to 100, total is 70 — one item is free.
  • A pushes Lamp; A writes total = 50; B pushes Rug; B writes total = 100. Invariant holds — the same two handlers, a different schedule, a correct result.
  • A pushes Lamp; B pushes Rug; B writes total = 70; A writes total = 50. Total is now *lower* than before B ran; the last writer wins and its base was stale.
  • A frozen PRICING table read by both handlers admits no failing schedule at all — every interleaving observes identical bytes.
What it guarantees — and does not
  • Nothing here is guaranteed by the language. Absence of sharing guarantees safety; presence of sharing guarantees nothing.
  • Immutability guarantees that every schedule observes the same value — it does not guarantee that the value is current. A reader may hold a snapshot taken before the latest swap.
  • Making a single field's write indivisible guarantees no torn value for that field. It does not guarantee that two related fields agree; see The Atomicity Illusion.
  • A single-threaded runtime guarantees no *preemption* mid-statement, which is much weaker than it sounds: it does not prevent a switch at every await. See Async Is Not Parallelism.
Where contention appears
  • None yet — this lesson is upstream of any primitive. Contention is what you buy when you fix the problem, not what the problem costs.
  • The relevant cost right now is *conceptual* contention: every future reader of this code must know that getCart returns a shared reference. Undocumented, that knowledge decays in one sprint.
  • The one measurable cost of the immutable alternative is allocation: one new cart per mutation instead of one field write. On a hot path that is a real budget item — see Copy or Share?.
How it fails
  • Lost update — two writers both base their write on the same stale read, and one write vanishes with no error.
  • Inconsistent read — a reader observes a state that no single task ever intended to publish (items updated, total not).
  • Silent divergence — the in-memory value and the persisted value drift apart over hours, discovered by a nightly reconciliation job rather than by an exception.
  • Aliasing surprise — a caller mutates what it believed was its own copy and changes what another task is reading, because a cache handed out the same reference twice.
When it helps
  • Sharing mutable state helps when the state genuinely is one thing: a connection pool, a rate-limiter bucket, an in-memory cache. Copying those defeats their purpose.
  • It helps when the state is large and updated far more often than it is copied — an index of a million entries updated once per request is not a good candidate for copy-on-write.
  • It helps when tasks must observe each other's effects promptly; a snapshot model deliberately delays that.
When it hurts
  • When the object is small and the update rate is modest — you have paid the full correctness cost to avoid an allocation nobody would have noticed.
  • When the state is shared only because it was convenient to cache it, not because it must be one thing. This is the most common cause of accidental sharing.
  • When ownership is unclear: several modules mutate it, nobody owns the invariant, and each new writer is a fresh chance to break it.
  • When the object escapes to code you do not control — handing a mutable internal structure to a plugin or callback makes every future concurrency bug someone else's contribution.
How you would know
  • Grep for module-level and static mutable containers. Each one is a shared-state candidate; each should have a comment naming its invariant and who may write it.
  • Look for functions that return a reference into a cache without copying or freezing — return this.cache.get(k) is the signature of accidental sharing.
  • Reconciliation counts, not error rates: a job that recomputes total from items and reports mismatches per hour is the only monitor that catches this class of bug.
  • In review, the question that finds it: "if this function ran twice, concurrently, on the same argument, what is the result?"
Complexity it introduces
  • Every piece of shared mutable state adds a rule that lives outside the type system: which lock, which thread, which phase of the lifecycle may touch it. Types cannot express it in most languages, so it decays into a comment.
  • It makes local reasoning impossible. To understand one function you must now know every other function that can run concurrently with it.
  • It makes tests unreliable in a specific way: passing tests stop being evidence, because the failing schedule is the one the test harness never produced.
  • It couples modules that share nothing else. Two features that both cache "the current user" are now one concurrency problem.
Simpler alternatives

Immutability lab

Mutate in place, or replace the whole thing
One structure, one writer moving 10 between two fields, and readers arriving at the worst possible moment.
Strategy
Invariant · a + b == 100 — every reader sees a total of exactly 100, whatever else is happening
#WriterReaderState
1account.a -= 10·a=40 b=50 a+b=90
2·read account.a, account.ba=40 b=50 a+b=90
✕ the reader observed a total of 90 — a state no writer ever intended
3account.b += 10·a=40 b=60 a+b=100
4·read account.a, account.ba=40 b=60 a+b=100
the structure is now half-updated
torn reads possible
yes
live versions
1
peak memory
0 MB
allocation churn
none
Mutation in place has no atomic step: the structure is inconsistent between the two field writes, and any reader arriving in that window observes a total of 90. Nothing is corrupted and no field is half-written — every individual value is fine. The relationship between them is what broke, and that is exactly the class of bug tests do not catch, because the window is two instructions wide and your test suite is single-threaded.
The honest price: 2 MB at peak against 0 MB, because the new version, the old version and every snapshot a reader is still holding are all alive at once — and a fresh allocation on every write. Structural sharing (persistent data structures, copy-on-write pages) shrinks the copy to the changed path rather than the whole object, which is why immutability at scale is a data-structure decision and not a coding style. If your structure is large, written constantly and read rarely, mutation under a lock is the cheaper answer and you should take it.
1/4 · mutationILLUSTRATIVE

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.

What people believe, and what is true

Claim

My handler does not share anything — it never touches a global.

Reality

Sharing arrives through references. A cache lookup, a singleton client, a closure over an outer variable and a default argument in Python all hand you state that other tasks reach too.

Claim

It is only shared if two threads touch it. My code is single-threaded async.

Reality

A single-threaded event loop still interleaves tasks at every await. The set of switch points is smaller, which makes the bug rarer and therefore harder to find, not absent.

Claim

Read-only access is always safe.

Reality

Read-only access is safe only if nothing writes. One writer plus many readers is the classic inconsistent-read bug — the readers are correct code observing an intermediate state.

Apply it