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.
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
yieldgets a number; the frame has astatefield that remembers which one it stopped at; resumeis an ordinary function call that starts withswitch (f.state)and jumps to the code right after that yield;yield vbecomes "f.state = k; return v": the call returns and its stack frame is popped, but the heap frame stays.
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.
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
| stackless | stackful | |
|---|---|---|
| memory per coroutine | one heap frame: the locals live across a yield, tens to hundreds of bytes | a whole stack: Lua about 1 KB, Go 2 KB that grows by copying, Boost.Context 128 KB by default |
| suspend from a nested call | only if every function on the way is a coroutine too | yes, at any depth |
| resume / suspend | a function call and return; a chain of N awaits is walked N levels | one register swap (a few ns), whatever the depth |
| needs | compiler support | a runtime or library that can switch stacks |
| calls into C or the OS | work as usual | a 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 / library | kind | written as |
|---|---|---|
| Python | stackless | generators (yield), async def / await |
| JavaScript | stackless | function*, async / await (driven by the event loop) |
| C#, Kotlin, Rust | stackless | async/await, suspend fun, async fn / .await |
| C++20 | stackless | co_await, co_yield, co_return |
| Lua | stackful | coroutine.create, resume, yield |
| Go | stackful | goroutines, switched by the Go scheduler, not by the program |
| Java 21 | stackful | virtual threads (the stack is copied to the heap when one parks) |
| C, C++ libraries | stackful | Boost.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.