The idea: a function that can pause

A normal function runs from start to end, then its frame is gone. A coroutine is a function that can stop in the middle (yield or await), give control back to its caller, and later resume exactly where it stopped, with its local variables intact. Caller and coroutine pass control back and forth on one thread. No OS thread is created and the kernel never sees a switch: it is all ordinary code in the process.

Time runs down. main calls resume, counter runs i = 1 and yields 1; main runs, resumes again, counter carries on after the yield with i = 2 and yields 2; one thread, control passes back and forth
resume hands control to the coroutine, yield hands it back; only one of them runs at a time, on one thread.

The hard part is the pause: where do the coroutine's locals and its "where was I" live while main runs? There are two answers, and the canvas runs the same program both ways side by side. On the left, stackless: the compiler turns the function into a state machine whose frame is an object on the heap. On the right, stackful: the coroutine gets a stack of its own, and a switch swaps the CPU's stack pointer. Pick a program (it loads at once), then press Resume once or Run to End.

Stackless: the compiler writes a state machine

Python generators and async def, JavaScript async functions and generators, C# async/yield return, Kotlin suspend, Rust async and C++20 co_await/co_yield are all stackless. The compiler rewrites the coroutine:

  • its locals become fields of a frame object allocated on the heap when the coroutine is created (c = counter() runs none of its code);
  • each yield gets a number; the frame has a state field that remembers which one it stopped at;
  • resume is an ordinary function call that starts with switch (f.state) and jumps to the code right after that yield;
  • yield v becomes "f.state = k; return v": the call returns and its stack frame is popped, but the heap frame stays.
Left: the source of counter with i = 1, a while loop and yield i. Right: what the compiler makes of it: a frame object on the heap with state and i, and counter.resume(f) that switches on f.state, case 0 sets i = 1, case 1 adds one, and yield becomes f.state = 1; return f.i
A stackless coroutine is a struct (state + locals) plus one function that switches on the state.

So between resumes the thread stack holds only main; everything the coroutine needs is a few dozen bytes on the heap (Demo 1: watch counter.resume(f) appear on the thread stack and disappear at every yield while the heap frame keeps state and i). Values go both ways: x = yield total receives what the caller passes to the next resume (gen.send(5) in Python, coroutine.resume(co, 5) in Lua; Demo 2).

Function colouring

The state machine can only stop at its own yields. If worker() calls an ordinary helper() and the helper wants to pause, there is nowhere to keep helper's frame: it lives on the thread stack, and returning to main pops it. So in a stackless language the helper must itself be a coroutine (async, a generator), and the caller must await / yield from it. That requirement spreads to every caller up the chain: functions get a "colour" (async or not), and an async one can only be awaited from another async one. This is function colouring.

At run time a chain of awaits is a chain of heap frames. Each resume enters the outer one, which resumes the inner one, and so on; a suspension returns all the way back up (Demo 3: worker.resume → helper.resume on every resume, and both pop at each yield). Python's yield from and C#'s awaiters work this way; C++20 and Rust shorten the walk with symmetric transfer and wakers.

Stackful: a stack per coroutine

Lua coroutines, Go goroutines, Boost.Context / Boost.Coroutine2 and Java virtual threads are stackful. Creating a coroutine allocates a stack for it. Resuming it is a call into a small switch routine that pushes the callee-saved registers, stores the stack pointer (SP) in the caller's context, loads the coroutine's SP, pops its registers and returns, which sets the program counter (PC) to wherever the coroutine stopped. yield is the same switch in the other direction.

Two stacks side by side: the main stack with main running, and worker's own stack with worker, helper(1) and yield(1), suspended two calls deep. The CPU's SP points at main; worker's saved SP and PC point inside yield(1)
A stackful switch only changes SP and PC, so the coroutine can pause any number of plain calls deep.

Because the whole call chain sits on the coroutine's own stack, yield can happen at any depth of ordinary function calls: no colouring, and one switch goes straight back into the deepest frame (Demo 3, right side). The price is memory: each coroutine owns a stack, which must be big enough for its deepest call or able to grow.

Memory and speed

stacklessstackful
memory per coroutineone heap frame: the locals live across a yield, tens to hundreds of bytesa whole stack: Lua about 1 KB, Go 2 KB that grows by copying, Boost.Context 128 KB by default
suspend from a nested callonly if every function on the way is a coroutine tooyes, at any depth
resume / suspenda function call and return; a chain of N awaits is walked N levelsone register swap (a few ns), whatever the depth
needscompiler supporta runtime or library that can switch stacks
calls into C or the OSwork as usuala C function that blocks blocks the whole thread

Neither involves the kernel: a coroutine switch is tens of nanoseconds, while switching OS threads costs microseconds (see the Linux context switch).

Who does what

language / librarykindwritten as
Pythonstacklessgenerators (yield), async def / await
JavaScriptstacklessfunction*, async / await (driven by the event loop)
C#, Kotlin, Ruststacklessasync/await, suspend fun, async fn / .await
C++20stacklessco_await, co_yield, co_return
Luastackfulcoroutine.create, resume, yield
Gostackfulgoroutines, switched by the Go scheduler, not by the program
Java 21stackfulvirtual threads (the stack is copied to the heap when one parks)
C, C++ librariesstackfulBoost.Context, ucontext, hand-written assembly

Goroutines and virtual threads go further than the page: a scheduler decides who runs next, so a goroutine never names the coroutine it switches to. How a goroutine's stack starts at 2 KB and grows, and how gogo / gosave swap it, is on Goroutine Internals.

Finished coroutines

When the coroutine returns, its result goes to the last resume and it is dead. Resuming it again is an error (Demo 4): a Python generator raises StopIteration again, an awaited Python coroutine raises RuntimeError: cannot reuse already awaited coroutine, Lua's coroutine.resume returns false, "cannot resume dead coroutine", and in C++20 resuming a handle that is done() is undefined behaviour, so check first. A coroutine that is never finished, like the running total in Demo 2, just stays suspended until it is collected or destroyed.

Coroutines switch only where they yield, which is cooperative scheduling; see Preemptive vs Cooperative scheduling for what a timer interrupt changes.

Python threads, unlike coroutines, are real OS threads but still take turns on the GIL.

What the page leaves out

Schedulers and event loops that resume coroutines when I/O is ready (the page's main resumes by hand), asymmetric vs symmetric transfer, exceptions thrown into a coroutine (gen.throw), cancellation, how C++ and Rust avoid the heap allocation (frame elision, futures stored inline), how stackful runtimes grow, copy or guard stacks, the exact registers saved per ABI, and thread-local storage seen from coroutines that move between threads.