Stackful vs. Stackless Coroutines: The Design Decision That Defines Your Async Runtime


Every modern language has an answer to the concurrency problem. Go has goroutines. Rust and JavaScript have async/await. Python has asyncio. On the surface they all do the same thing—let you write non-blocking code that looks roughly sequential—but the underlying mechanism splits into two distinct camps: stackful and stackless coroutines. That split has more consequences for language design, memory usage, and developer ergonomics than most engineers realize.

What a Coroutine Actually Is

A coroutine is a function that can suspend its execution midway and be resumed later, without blocking the thread it runs on. The runtime can keep thousands or millions of them alive simultaneously, switching between them when one is waiting on I/O or otherwise idle. The key design question is: where does the suspended state live?

Stackful Coroutines: A Stack Per Coroutine

Stackful coroutines—sometimes called green threads or fibers—give each coroutine its own call stack, typically heap-allocated. When a coroutine suspends, the runtime saves its stack pointer and switches to another coroutine’s stack. The suspended state is whatever was on the stack at the moment of suspension: local variables, return addresses, the full call chain.

Go goroutines work exactly this way. You call go f(), and the runtime allocates a small initial stack—roughly 2–8 KB—that grows dynamically as needed. The Go scheduler multiplexes goroutines across OS threads using a work-stealing algorithm. Crucially, a goroutine can suspend anywhere in the call stack, even deep inside a library call, without that library knowing anything about goroutines. From the library’s perspective, nothing special is happening.

The cost is memory. Each coroutine needs at least a minimum stack, even when idle. Spawn a million goroutines and you’ve committed to a million stacks. Go mitigates this with a copy-and-grow strategy that keeps initial allocation small, but the baseline overhead is still real compared to the alternative.

Stackless Coroutines: State Machines at Compile Time

Stackless coroutines take a different approach: the compiler transforms each async function into a state machine. Instead of a separate stack, the coroutine is represented as a struct holding only the variables it actually needs to survive across suspension points.

Rust’s async/await is the canonical example. When you write an async fn, the compiler generates an enum with one variant per await point, storing exactly the locals in scope at each possible suspension. The entire coroutine state might be a few dozen bytes if that’s all the function requires.

The memory efficiency is meaningful at scale. A Rust future at rest can be orders of magnitude smaller than a Go goroutine with its minimum stack. At the scale of millions of concurrent tasks—connection handlers, streaming pipelines, IoT device sessions—the difference can determine whether a workload fits in memory at all.

The tradeoff is that suspension can only happen at explicit await points. You cannot call a blocking function from inside an async function without either marking that function async too, or explicitly offloading it to a thread pool. This is often called the function coloring problem: async is infectious. Every function that might await must be marked async all the way up the call stack. Calling async code from sync code requires a bridge. Calling sync code from async code risks blocking the executor thread entirely.

How This Shapes Language Design

Function coloring is precisely why Go has no async keyword. Every function can block whenever it wants—the runtime handles it transparently. This is a genuine ergonomic win. Writing Go concurrency feels natural because there’s no async/sync boundary to manage. The cost is accepting the memory and runtime overhead of per-goroutine stacks.

Rust made the opposite bet: pay the complexity cost at the language level to get zero-cost abstractions at runtime. The async keyword is explicit and infectious by design, which lets the compiler generate minimal state machines. An async Rust program with millions of concurrent tasks can run with a fraction of the memory of the equivalent Go program—but the developer explicitly owns the async/sync boundary.

JavaScript lands somewhere in the middle: stackless async/await with a single-threaded event loop, where function coloring is usually manageable because the ecosystem standardized on async early enough that libraries are consistently colored.

Where the Distinction Surfaces

For typical application code the implementation is invisible—your runtime handles scheduling. It becomes relevant when you need to call blocking code from an async context (expensive in stackless runtimes, essentially free in stackful ones), spawn a very large number of concurrent tasks (cheaper in stackless), or integrate third-party libraries that predate your concurrency model (far easier in stackful runtimes).

Understanding the mechanism also explains why Go idioms feel awkward in Rust, why async Rust can be unforgiving about library boundaries, and why frameworks like Tokio need explicit spawn_blocking escapes for any synchronous work. The language isn’t being difficult—it’s being consistent with its own cost model.