The question this answers
Two tasks can both reach the same state — when does that create a coordination requirement, and when does it create none at all?
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.
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.
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.
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.
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 |
|---|---|---|---|
| No | Yes | None | Task-local state. The cheapest correct answer; prefer it whenever the work can be phrased as "compute a value and return it". |
| Yes | No | None | Frozen config, interned constants, a snapshot handed out by value. Document that it is frozen so nobody adds a setter. |
| Yes | Yes — one writer, many readers | Yes | Readers can observe a half-finished update. Publish a new immutable value, or use a read/write lock. See Read/Write Locks, Honestly. |
| Yes | Yes — several writers | Yes | The 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. |
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.
| # | Handler A — add "Lamp" (30) | Handler B — add "Rug" (50) | State |
|---|---|---|---|
| 1 | read cart.items (length 1, total 20) | · | items=[Pen 20] total=20 |
| 2 | items.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 |
| 5 | write cart.total = 20 + 30 | · | items=[Pen 20, Lamp 30, Rug 50] total=50 |
| 6 | · | write cart.total = 20 + 50 | items=[Pen 20, Lamp 30, Rug 50] total=70 ✕ items sum to 100; total reads 70. The Lamp is in the basket and free. |
| 7 | respond 200 { total: 50 } | · | total=70 |
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.
- • 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
awaiton 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.
- • 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
PRICINGtable read by both handlers admits no failing schedule at all — every interleaving observes identical bytes.
- • 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.
- • 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
getCartreturns 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?.
- • 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.
- • 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 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.
- • 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
totalfromitemsand 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?"
- • 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.
- • Do not share: give each task its own copy, compute, and merge at a single controlled point. Often faster than the lock you were about to add, and always simpler.
- • Share, but immutably: publish a new frozen value and swap the reference. Readers never block; see Immutability as a Concurrency Strategy and Copy-on-Write as a Concurrency Strategy.
- • Do not mutate in the process at all: make the database the only mutable copy and let it arbitrate with transactions. See The Database Solves Concurrency For Its Data, Not For Your Memory — but note it does nothing for your in-process cache.
- • Move the data instead of sharing it: hand ownership to one task through a queue or channel so exactly one actor ever writes. See Message Passing and The Actor Model.
Immutability lab
| # | Writer | Reader | State |
|---|---|---|---|
| 1 | account.a -= 10 | · | a=40 b=50 a+b=90 |
| 2 | · | read account.a, account.b | a=40 b=50 a+b=90 ✕ the reader observed a total of 90 — a state no writer ever intended |
| 3 | account.b += 10 | · | a=40 b=60 a+b=100 |
| 4 | · | read account.a, account.b | a=40 b=60 a+b=100 |
Two increments, twenty schedules: find the one that loses an update
| # | Task A — counter++ | Task B — counter++ | State |
|---|---|---|---|
| 1 | rA ← counter | · | counter=0 rA=0 rB=0 |
| 2 | rA ← rA + 1 | · | counter=0 rA=1 rB=0 |
| 3 | counter ← rA | · | counter=1 rA=1 rB=0 |
| 4 | · | rB ← counter | counter=1 rA=1 rB=1 |
| 5 | · | rB ← rB + 1 | counter=1 rA=1 rB=2 |
| 6 | · | counter ← rB | counter=2 rA=1 rB=2 |
What people believe, and what is true
My handler does not share anything — it never touches a global.
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.
It is only shared if two threads touch it. My code is single-threaded async.
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.
Read-only access is always safe.
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.