Processes, Threads & Tasks

Coroutines: Functions That Can Pause

An ordinary function has two control points — call and return. A coroutine has four: call, suspend, resume, return. That single addition is what lets one thread hold ten thousand in-progress operations, and it is also what silently removes the atomicity your code was relying on without ever saying so.

▶ Run the lab

The question this answers

The question

What does it mean for a function to pause in the middle, and who decides when it resumes?

The work

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.

What is shared

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.

The invariant — what must stay true under every interleaving

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.

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?

Four control points instead of two

language-specific· CPython 3.12. Generators and native coroutines share the suspend/resume machinery; `async def` adds awaitable protocol and a scheduler.

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 value
5 if payout is None:
6 return total # ...still intact across every suspension
7 total += payout # runs only when someone resumes us
8
9c = settle("acct-9")
10next(c) # run to the first yield: 0
11c.send(40) # resume, run to the next yield: 40
12c.send(25) # resume again: 65
13# Between those two send() calls, this coroutine was a heap object with a
14# saved instruction pointer and a saved 'total'. It occupied no thread,
15# no stack and no core, and any other code could run -- including code
16# 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] # read
21 await provider.dispatch(payout) # SUSPEND -- other coroutines run here
22 ledger[account] = balance - payout # write, using a value read before the gap
A coroutine, stripped to the mechanism: suspend, resume, and locals that survive both.

Who 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.

ModelWho stops youWhere a switch can happenWhat you get for freeWhat it costs
OS threadsThe kernel scheduler, at any timeBetween any two machine instructionsA monopolising computation cannot freeze others; it just uses a coreNothing is atomic. Every shared read-modify-write must be synchronised explicitly.
Cooperative coroutinesOnly you, at an explicit yield or awaitExactly at the suspension points you wroteImplicit atomicity between suspension points — no lock needed for a gap-free regionA fragment that never suspends freezes every coroutine on that scheduler
Runtime-scheduled tasksThe runtime, but usually only at suspension pointsAt suspension points; at compiler-inserted preemption points in some runtimesMulti-core execution plus cheap suspensionBoth cost columns above, plus tasks migrating threads mid-lifetime
Generators (manual)You, and the caller decides when to resumeAt each yield, driven by explicit next()/send()Total control over interleaving — useful for deterministic testingYou are the scheduler. Nothing runs unless you resume it.
Who can take the execution context away, and what that implies.

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]].

Two settlement coroutines on one cooperative scheduler, after an `await` moved into the critical region.ILLUSTRATIVE
Invariant · ledger["acct-9"] never goes negative; the sum of dispatched payouts never exceeds the starting balance of 100.
#Coroutine 1 — payout 80 for acct-9Coroutine 2 — payout 80 for acct-9State
1read ledger["acct-9"] → 100·balance=100 dispatched=0
2check 80 <= 100 → allowed·balance=100 dispatched=0
3await provider.dispatch(80) — SUSPENDS·balance=100 dispatched=0
4·read ledger["acct-9"] → 100balance=100 dispatched=0
5·check 80 <= 100 → allowedbalance=100 dispatched=0
6·await provider.dispatch(80) — SUSPENDSbalance=100 dispatched=80
7dispatch returns OK; write ledger = 100 - 80 = 20·balance=20 dispatched=160
8·dispatch returns OK; write ledger = 100 - 80 = 20balance=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.
The account is overdrawn by 60 and the ledger does not show it, because both coroutines subtracted from the same stale snapshot. No new shared state was introduced, no lock was removed, and the diff was three lines. The atomicity that made version one correct was a side effect of having no suspension point in the region — an invariant nothing recorded and nothing enforced.

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 await inside 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.

How it works
  • 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.
Interleavings that matter
  • 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.
What it guarantees — and does not
  • 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]].
Where contention appears
  • 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.
How it fails
  • 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.
When it helps
  • 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.
When it hurts
  • 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.
How you would know
  • 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 await expressions inside regions that mutate shared state — a static check, and a genuinely effective one in review.
Complexity it introduces
  • 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.
Simpler alternatives
  • 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

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.

Scheduler timeline

Scheduler timeline
Tasks over cores, one tick per column. Watch which lanes run, which sit ready, and which are blocked on I/O.
Task 1
ready
ready
blocked
ready
Task 2
ready
ready
blocked
ready
ready
ready
ready
Task 3
ready
ready
ready
ready
ready
blocked
Task 4
ready
ready
ready
ready
Task 5
ready
ready
ready
ready
ready
ready
runningreadywaitingblockedidle1 column = 1 scheduler quantum
running now
1 / 1
ready queue
4
blocked on I/O
0
context switches
0
Ready-queue depth4 waiting for a core
One core: exactly one lane is `running` in every column, yet several tasks advance across the run. That is concurrency without parallelism — the definition, drawn.
A switch is counted whenever a core’s occupant changes between columns; the model charges 0.05 ms for each one. Real switch cost depends on the cache footprint the outgoing task leaves behind and is usually worse than a constant. Mechanism lives in Operating Systems — this view is about what the schedule means.
1/40 · tick 1SIMULATED

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

A coroutine is a lightweight thread.

Reality

It is a resumable function frame on the heap. Nothing schedules it unless a scheduler does, and it occupies no execution resource while suspended.

Claim

Cooperative scheduling means I do not need locks.

Reality

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.

Claim

My locals were safe across the await, so my state is safe.

Reality

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.

Claim

A mutex is a mutex.

Reality

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.

Apply it