The question this answers
What does it mean for a function to pause in the middle, and who decides when it resumes?
A settlement worker processing a queue of payouts: for each payout, check the account balance held in memory, call the payment provider, then decrement the balance. Written as a coroutine so ten thousand payouts can be in flight.
The in-memory balance ledger, reachable by every coroutine, and the provider client's connection pool. Each coroutine's own locals are private and survive suspension.
An account balance never goes negative: the sum of all payouts dispatched against an account never exceeds the balance that account had when the batch started.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Four control points instead of two
A subroutine has a strictly nested lifetime: it is called, it runs, it returns, and its frame is destroyed. A coroutine can also *suspend* — save its resumption point and its live locals, hand control back to whoever is scheduling it, and be resumed later, possibly much later, possibly by a different thread.
The frame therefore cannot live on the calling thread's stack, because the stack unwinds while the coroutine is still alive. It lives on the heap, which is exactly why ten thousand suspended coroutines cost a few megabytes rather than eighty gigabytes. It is also why a coroutine can outlive the scope that created it, which is the mechanism behind orphaned tasks and the reason [[structured-concurrency]] exists.
The example below is deliberately a plain generator rather than async def, because it makes the machinery visible: yield is the suspension point, send() is the resume, and the local total survives across both without anyone writing a save or a restore. Everything async/await does is this plus a scheduler that decides when to call send() for you.
1def settle(account):2 total = 0 # a local...3 while True:4 payout = yield total # SUSPEND here; resume with the sent value5 if payout is None:6 return total # ...still intact across every suspension7 total += payout # runs only when someone resumes us8 9c = settle("acct-9")10next(c) # run to the first yield: 011c.send(40) # resume, run to the next yield: 4012c.send(25) # resume again: 6513# Between those two send() calls, this coroutine was a heap object with a14# saved instruction pointer and a saved 'total'. It occupied no thread,15# no stack and no core, and any other code could run -- including code16# that mutates whatever 'settle' is about to read.17 18# async/await is the same machine with a scheduler doing the send():19async def settle_async(account, payout):20 balance = ledger[account] # read21 await provider.dispatch(payout) # SUSPEND -- other coroutines run here22 ledger[account] = balance - payout # write, using a value read before the gapWho decides when you stop running
Three scheduling models, and the difference between them is entirely about who can take the execution context away from you. With OS threads, the kernel can preempt between any two instructions — you never chose to stop and you cannot prevent it. With cooperative coroutines, only your own yield or await stops you; a coroutine that computes for four seconds computes for four seconds and nothing else on that scheduler runs. Runtime-scheduled tasks sit in between: the runtime schedules them, but it can still only regain control at a suspension point unless it has inserted preemption points for you.
The safety consequence cuts both ways and this is the part worth internalising. Preemption exposes every non-atomic operation, so you must synchronise defensively — but it also guarantees that a monopolising computation cannot freeze the system. Cooperation gives you implicit atomicity between suspension points, which is genuinely useful, and buys it with the guarantee that any un-yielding fragment stalls everything.
The implicit atomicity is the trap. It is real, it is load-bearing in a lot of code, and it is invisible: nothing marks the region, no type system tracks it, and any future edit that adds an await in the middle silently removes it. That is the subject of the schedule below.
| Model | Who stops you | Where a switch can happen | What you get for free | What it costs |
|---|---|---|---|---|
| OS threads | The kernel scheduler, at any time | Between any two machine instructions | A monopolising computation cannot freeze others; it just uses a core | Nothing is atomic. Every shared read-modify-write must be synchronised explicitly. |
| Cooperative coroutines | Only you, at an explicit yield or await | Exactly at the suspension points you wrote | Implicit atomicity between suspension points — no lock needed for a gap-free region | A fragment that never suspends freezes every coroutine on that scheduler |
| Runtime-scheduled tasks | The runtime, but usually only at suspension points | At suspension points; at compiler-inserted preemption points in some runtimes | Multi-core execution plus cheap suspension | Both cost columns above, plus tasks migrating threads mid-lifetime |
| Generators (manual) | You, and the caller decides when to resume | At each yield, driven by explicit next()/send() | Total control over interleaving — useful for deterministic testing | You are the scheduler. Nothing runs unless you resume it. |
The refactor that added a race
Version one of the settlement worker read the balance, decremented it, and then dispatched the payout. No suspension point between the read and the write, so on a cooperative scheduler the region was atomic — not by design, but by the absence of an await inside it. It was correct for two years.
Version two moved the dispatch between the read and the write, because someone sensibly wanted to avoid decrementing a balance for a payout that failed. The diff is three lines and contains no synchronisation change, no new shared state and no new concurrency. Reviewers approved it. It introduced the schedule below, in which two coroutines both read a balance of 100 and both dispatch 80.
This is why "we are cooperative so we do not need locks" is a dangerous thing for a codebase to believe. The property it depends on is *the absence of a suspension point inside a region*, which is a property no tool checks and every refactor can break. The fix in an async runtime is an async lock or a permit per account acquired before the read and released after the write — and specifically not a thread lock, which would block the whole scheduler. See [[finding-the-critical-section]] and [[semaphores-and-permits]].
| # | Coroutine 1 — payout 80 for acct-9 | Coroutine 2 — payout 80 for acct-9 | State |
|---|---|---|---|
| 1 | read ledger["acct-9"] → 100 | · | balance=100 dispatched=0 |
| 2 | check 80 <= 100 → allowed | · | balance=100 dispatched=0 |
| 3 | await provider.dispatch(80) — SUSPENDS | · | balance=100 dispatched=0 |
| 4 | · | read ledger["acct-9"] → 100 | balance=100 dispatched=0 |
| 5 | · | check 80 <= 100 → allowed | balance=100 dispatched=0 |
| 6 | · | await provider.dispatch(80) — SUSPENDS | balance=100 dispatched=80 |
| 7 | dispatch returns OK; write ledger = 100 - 80 = 20 | · | balance=20 dispatched=160 |
| 8 | · | dispatch returns OK; write ledger = 100 - 80 = 20 | balance=20 dispatched=160 ✕ 160 dispatched against a balance of 100, and the ledger reads 20 as though only one payout occurred. C2 wrote a value computed from a balance read before C1's write. |
Key points
- A coroutine has four control points — call, suspend, resume, return — instead of a subroutine's two.
- Its frame lives on the heap, which is why suspended coroutines are cheap and why they can outlive the scope that created them.
- Locals survive suspension automatically; the shared state they read before suspending does not stay still.
- Cooperative scheduling means only your own yield or await stops you: no preemption, so a non-suspending fragment freezes everything on the scheduler.
- Cooperation grants implicit atomicity between suspension points. It is real, it is load-bearing, and it is invisible to review.
- Adding an
awaitinside a previously gap-free region silently removes that atomicity, with a diff that contains no synchronisation change. - In async code use async-aware locks and permits; a thread lock blocks the scheduler and can deadlock the whole runtime.
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.
- • Calling a coroutine function creates a frame object on the heap containing the instruction pointer and the locals, and runs nothing yet.
- • Resuming it executes from the saved instruction pointer until the next suspension point or a return.
- • Suspending saves the instruction pointer and the live locals into that heap frame and returns control to the caller or the scheduler.
- • While suspended the coroutine occupies no stack, no thread and no core — it is a data structure, not an execution.
- • A scheduler (an event loop, a runtime, or explicit
send()calls) decides which suspended coroutine to resume and when. - • Returning destroys the frame and delivers the result to whoever was awaiting it; an exception propagates to the same place, and if nobody is awaiting, it can be lost entirely.
- • C1 reads, decrements, then dispatches — no suspension point between read and write, so the region is atomic by construction and the invariant holds.
- • C1 reads, dispatches (suspends), C2 reads, both write — the overdraft above, produced by moving one line.
- • C1 acquires a per-account async lock before the read and releases after the write; C2 suspends on the lock rather than proceeding, and the invariant holds with the scheduler still free to run unrelated accounts.
- • C1 takes a *thread* lock instead and awaits inside it: the scheduler thread is blocked, no other coroutine can run, and if the awaited operation needs the scheduler to progress, the process deadlocks with a runnable-looking stack.
- • C1 computes a 4-second reconciliation with no await: every other coroutine on the scheduler is stalled for 4 seconds despite having nothing to do with reconciliation.
- • Suspension guarantees the coroutine's own locals are exactly as it left them when it resumes.
- • It guarantees nothing about shared state, which other coroutines were free to modify during the gap.
- • Cooperative scheduling guarantees no switch occurs except at a suspension point — an atomicity guarantee that holds only as long as the region contains none.
- • It does not guarantee promptness: a coroutine that becomes ready waits for the scheduler, and for whatever fragment is currently monopolising it.
- • It does not guarantee the same thread on resume in runtimes that migrate tasks, so anything thread-affine is unsafe across a suspension point.
- • Nothing guarantees a coroutine is ever resumed at all. An abandoned coroutine simply never runs again, and its cleanup never happens. See
[[orphaned-tasks]].
- • Contention for the scheduler itself: ready coroutines queue behind whatever fragment is currently running, with no lock and usually no metric involved.
- • Contention on shared state across suspension points, which is the same contention threads have and is frequently assumed away because "it is one thread".
- • Contention on the provider connection pool: ten thousand coroutines and twenty connections means the pool wait is the real latency.
- • Async locks add queueing that is invisible to OS-level tools — no thread is blocked, so a thread dump shows a healthy process with everything stuck.
- • Race condition across a suspension point, producing lost updates or overdrafts on a single thread.
- • Scheduler starvation from a fragment with no suspension point, stalling every coroutine including health checks.
- • Deadlock from using a blocking thread primitive inside a coroutine, where the blocked thread is the one that would have resumed the awaited operation.
- • Orphaned coroutine: created, never awaited, exception never observed, cleanup never run.
- • Unbounded coroutine creation: they are cheap, so nothing stops a million of them, and the heap is the limit. See
[[unbounded-concurrency]]. - • Lost cancellation: cancelling a coroutine that is between suspension points does nothing until it next suspends.
- • Very high concurrency over waiting-bound work, where the per-unit cost is a heap frame rather than a stack.
- • Expressing sequential logic that must pause: state machines, protocol handlers, streaming parsers, and anything where callbacks would fragment the control flow.
- • Deterministic testing, where a manually-driven generator lets you choose the interleaving and reproduce a race on demand. See
[[deterministic-replay]]. - • Structured lifetimes: coroutines compose into scopes that cancel their children, which threads do not do naturally.
- • CPU-bound work: a coroutine that never suspends is a plain function that has taken the scheduler hostage.
- • Codebases that mix blocking and coroutine styles, where one synchronous call in a library defeats the model for everything sharing the scheduler.
- • Teams that read "cooperative" as "safe", which produces precisely the schedule above.
- • Debugging: the stack at a suspension point contains the scheduler rather than the logical caller, so causality has to be reconstructed from context you propagated deliberately.
- • Longest uninterrupted fragment between suspension points. Anything beyond a few milliseconds is a scheduler-starvation risk.
- • Scheduler lag — the delay between a coroutine becoming ready and actually resuming — which is the only direct evidence of a monopolising fragment.
- • Live coroutine count as a gauge, plus the count of coroutines awaiting each distinct resource, which localises a stall immediately.
- • Async task dumps during a hang: the distribution of suspension points names what everything is waiting for. See
[[async-task-dumps]]. - • Count of
awaitexpressions inside regions that mutate shared state — a static check, and a genuinely effective one in review.
- • Every suspension point must be known, including inside libraries, because each is an interleaving point for every invariant that spans it.
- • Two synchronisation vocabularies coexist and must not be mixed: async primitives suspend, thread primitives block, and mixing them is a latent deadlock.
- • Implicit atomicity is a hazard rather than a feature once a codebase depends on it, because nothing records or enforces it.
- • Cancellation and cleanup need explicit design: a suspended coroutine that is dropped runs no cleanup unless the language guarantees it.
- • The async colouring problem: coroutine-ness propagates up the call graph, so retrofitting is a wide change rather than a local one.
- • Threads with blocking calls, when concurrency is in the tens and the simplicity is worth the stacks. See
[[threads]]. - • Virtual or green threads, where available: coroutine-like cost with blocking-style code, removing the colouring problem.
- • An explicit state machine, when there are few states and the control flow must be inspectable, serialisable or resumable across a restart.
- • A bounded queue and a worker pool, when the goal is limiting concurrent work rather than expressing pausable logic.
The lost update, step by step
| # | Task A — counter++ | Task B — counter++ | State |
|---|---|---|---|
| 1 | rA ← counter | · | counter=0 rA=0 rB=— |
| 2 | · | rB ← counter | counter=0 rA=0 rB=0 |
| 3 | rA ← rA + 1 | · | counter=0 rA=1 rB=0 |
| 4 | counter ← rA | · | counter=1 rA=1 rB=0 |
| 5 | · | rB ← rB + 1 | counter=1 rA=1 rB=1 |
| 6 | · | counter ← rB | counter=1 rA=1 rB=1 ✕ 2 increments completed, counter = 1 |
Scheduler timeline
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
A coroutine is a lightweight thread.
It is a resumable function frame on the heap. Nothing schedules it unless a scheduler does, and it occupies no execution resource while suspended.
Cooperative scheduling means I do not need locks.
It means you do not need them inside a region containing no suspension point. That is a property of the code today, and no tool will tell you when a refactor removes it.
My locals were safe across the await, so my state is safe.
The locals are safe precisely because they are private. The shared state you read into them before the gap is exactly what other coroutines were free to change.
A mutex is a mutex.
A thread mutex blocks the scheduler thread. In a coroutine you need one that suspends the coroutine, or you take the entire runtime down with you.
Go deeper
Overview
A coroutine is a function that can pause and continue later. Its frame lives on the heap, so thousands of paused ones are cheap.
Practical
Treat every yield or await as a place where other code runs. Never span one with a read-modify-write on shared state, never hold a thread lock across one, and never compute for long between two of them.
Advanced
Cooperative scheduling gives you an atomicity guarantee defined by the absence of suspension points, which makes it a guarantee about source code rather than about semantics. It is therefore not composable and not stable under refactoring — the reason mature async codebases use explicit async locks even where "nothing can interleave here" is true today.