JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Coroutines: Stackful vs Stackless

Async/await let a function pause and resume — but where does its half-finished state actually live while it waits? The answer splits the whole world of coroutines in two, and once you see the split, every runtime you meet falls neatly onto one side or the other.

The question async/await forced on us

In the last guide you watched a function hit an `await`, hand control back to the runtime, and later wake up exactly where it left off — its locals intact, ready to run the next line. We waved at how that resume works and moved on. Now we open the box. The thing that can suspend and resume in the middle is called a coroutine, and the single hardest question about one is brutally physical: while it is paused, where do its local variables actually sit in memory? An ordinary function keeps its locals in a stack frame on the call stack — but that frame is gone the instant the function returns, and pausing is a kind of returning. So the state has to live somewhere that survives the pause.

There are exactly two honest answers, and they name the great divide of this guide. Stackful: give the coroutine its own complete, private call stack — a real region of memory with its own stack pointer — and when it pauses, just stop touching that stack and remember where rsp was; the locals never moved, they are simply sitting frozen on a stack nobody is currently using. Stackless: do not give it a stack at all; instead, before pausing, copy the few variables that must survive into a small heap-allocated object, and on resume read them back out. Both work. Both are real. They have wildly different costs, and which one your language picked shapes everything from how much memory a million tasks burn to whether you can pause from deep inside a helper function.

Stackful: a frozen stack you can step away from

Picture the stackful coroutine as a worker with their own desk. When the runtime starts it, it allocates a fresh stack — say 64 KiB or 1 MiB of memory — sets rsp to the top of that region, and jumps in. The coroutine runs normally: it calls functions, those calls push frames, locals pile up exactly as on the main stack, because it really is a stack. When it reaches an `await`, the runtime does something delightfully simple: save the current rsp (and a couple of other registers) into the coroutine's bookkeeping object, then load the rsp of whoever should run next and jump there. That is a context switch done entirely in user space — no kernel, no system call — just swapping which stack the single rip is walking.

The beauty of this is that you can pause from anywhere. Because the suspend point just freezes a real stack, it does not matter how deep you are — you can be ten function calls down inside some library helper, hit an `await`, and the whole tower of frames freezes together and thaws together later. The code reads like perfectly ordinary blocking code; nothing in the function signatures has to announce "I might suspend." This property has a name worth remembering: stackful coroutines support suspending across arbitrary call depth. Goroutines in Go and fibers in many C++ libraries work exactly this way, which is why a Go function deep in a call chain can block on a channel and the language just handles it.

The cost is also physical, and you should be honest about it. Each coroutine needs a whole stack reserved up front, and you cannot know how deep it will recurse, so you must guess generously or pay to grow it. A fixed 1 MiB stack times a million coroutines is a terabyte of address space — which is why stackful runtimes work hard at this: Go starts goroutines with a tiny ~2 KiB to ~8 KiB stack and grows it by copying to a bigger one when it overflows, and that copy means every pointer into the stack must be found and rewritten. That machinery is exactly the cost stackless coroutines exist to avoid.

Stackless: a struct that is a paused function

The stackless answer refuses to allocate a stack at all. Its insight is that a paused function does not actually need its whole stack frozen — it only needs the handful of variables that are still alive across the pause, plus one number: where to resume. So the compiler does a remarkable transformation. It looks at your `async` function, finds every variable whose lifetime crosses an `await`, and packs exactly those into a small struct — a state machine — together with an integer state field. The function body is rewritten into a switch: "if state is 0, run from the top to the first await; if state is 1, resume after the first await," and so on. The struct is the suspended coroutine.

async fn read_two(sock) -> (Msg, Msg):       // what you write
    let a = await recv(sock)
    let b = await recv(sock)
    return (a, b)

// roughly what the compiler builds:
struct ReadTwo { state: int; sock: Socket; a: Msg; }   // only a survives the 2nd await; b never does

fn poll(self) -> Poll:
    switch self.state:
      case 0:  self.state = 1; return suspend_until(recv ready)   // a not yet set
      case 1:  self.a = recv(self.sock); self.state = 2; return suspend_until(recv ready)
      case 2:  let b = recv(self.sock); return Done((self.a, b))
A two-await async function and the state machine a stackless compiler lowers it to. The struct holds only what must cross a suspend point; the integer state field is the 'resume here' marker. Each call to poll() runs one chunk between awaits and returns whether it is done.

Now count the cost. That struct is exactly as big as it needs to be — no guessed-at stack, no waste — and it lives wherever you put it: on the heap, inside another future, in an array. A million of these tasks costs a million small structs, often a few hundred bytes each, not a million stacks. This is why stackless async is the model for runtimes that want enormous numbers of tiny concurrent tasks cheaply, and it is exactly how Rust's `async`/`await`, C++20 coroutines, and the async functions of C#, JavaScript, and Python are built. The bookkeeping object that drives it is the future from the last guide; calling its `poll` runs one slice between awaits, which is why the runtime that owns the loop is an executor.

What you give up: the function-color problem

Stackless is not free; it trades memory for a sharp limitation. Because the compiler builds the state machine by looking at one function's body, the suspend points must be visible inside that function. You cannot be ten ordinary calls deep and just suspend — the helper you are inside has no state machine, no place to stash its locals across a pause. So the language makes you mark the chain: a function that can suspend must be declared `async`, and to wait on one you must `await` it, which means the caller must itself be `async`, all the way up. This is the famous function-color problem: `async` functions and plain functions are different colors, and a plain function cannot transparently call an async one. Stackful coroutines simply do not have this problem, because they freeze the real stack and do not care what is on it.

There is one more stackless subtlety worth meeting now, because it bites real Rust programmers. Since the coroutine's surviving locals live inside one struct, a local that is a pointer to another local in the same struct creates a self-reference: the struct points into itself. If the runtime ever moved that struct to a new address — say while growing a vector that holds it — the internal pointer would now dangle, a textbook dangling pointer. This is precisely why Rust grew the `Pin` mechanism: it is a promise that, once polled, an async future will never be moved, so those self-references stay valid. It is a real wart, an honest cost of the stackless design, not a detail to gloss over.

Where coroutines sit in the bigger machine

Step back and the coroutine slots into the picture this whole rung has been building. A coroutine is a unit of work that can pause; an executor is the loop that resumes ready ones; and underneath sits a small pool of real OS threads. Many coroutines ride on few threads — that is the M-to-N threading model from earlier, M lightweight tasks multiplexed onto N kernel threads, and the coroutine is precisely what an M is made of. Whether those tasks are stackful or stackless is an implementation choice about how each M stores its paused state; the scheduling story sitting above them is the same either way.

It also helps to connect the word coroutine back to the deeper idea under it: a continuation. A continuation is "all the work still left to do" captured as a value you can call later. Resuming a coroutine is exactly invoking its continuation — the stackful version stores that continuation as a frozen stack, the stackless version stores it as a state-machine struct, but the concept is identical: freeze the rest of the computation, carry it around, run it when the world is ready. Seeing both styles as two encodings of one continuation is the cleanest mental unification this guide can give you.

One honest caveat before you climb on. Coroutines change how work waits, not whether the underlying I/O can block. An `await` only yields the thread if the thing being awaited actually suspends cooperatively — call a blocking syscall like a synchronous read() inside an async function and you stall the whole executor thread, freezing every other coroutine sharing it. That is the rung-3 rule "don't block the executor" wearing a coroutine costume. Coroutines are a beautifully cheap way to structure waiting; they do not magically make a blocking call non-blocking. Keep that line bright, and the next guide — where these tasks get organized into scopes, channels, and actors — will rest on solid ground.