fastwired@reader:~/fastwired/ $ cat posts//concurrency-models-for-io.md

Concurrency models for I/O: fibers, coloring, and Zig's third way

Under the hood, everyone polls an event loop. The real question is who pays for the yield.

Every program that does I/O eventually hits the same awkward moment: a task asks for something from the network or the disk, and now it has to wait. A few milliseconds, maybe, but on the CPU's timescale that's an eternity, and the whole time the core is sitting there doing nothing. So the obvious question is: while that task waits, what runs?

Here's the thing. Every server framework, every runtime, every async library you've ever used is really just an answer to that one question. And once you strip away the marketing, there aren't that many fundamentally different answers, just a handful of structural models, each with its own idea of where the bookkeeping lives and who ends up paying for it.

Let's map them out. No benchmarks, I promise; just the shapes of the designs and why they end up so far apart from one another.

The problem, briefly

An I/O operation (reading a socket, querying a database) takes orders of magnitude longer than a CPU instruction, and for most of that time the CPU has nothing to do. To keep it busy you need two things:

  1. A way to find out when the I/O can actually proceed, without burning cycles spinning in a loop. On modern kernels that's epoll/kqueue (tell me when it's ready) or io_uring/IOCP (tell me when it's done).
  2. A way to run other work while the first task is parked, and to pick it back up later.

The first part is a solved problem; the kernel gives it to you. The second part is where all the interesting disagreement lives. And underneath that disagreement are two questions that almost everyone conflates into one. Let's pull them apart first, because once you see them as separate, the whole zoo of runtimes suddenly makes sense.

Question one: where does a sleeping task keep its stuff?

When a task parks mid-flight, its state has to go somewhere. And a running function is more than just a line number. It's got local variables, and it's usually sitting somewhere inside a chain of calls (handleRequestparseBodyread). To freeze all of that and thaw it out later, intact, you have two options.

Option A: give every task its own stack. This is the “stackful” approach. Suspending is dead simple: the scheduler swaps the CPU's stack pointer over to some other task's stack, and swaps it back when it's time to resume. The frozen call chain just sits there on the private stack, untouched. And because the entire chain lives on that stack, a task can suspend from anywhere, even ten frames deep inside some library that has no idea suspension is even a thing. Nobody has to mark the yield point. It doesn't even have to be visible in the source.

I want to stress that this is an implementation detail, not a philosophy. OS threads are stackful. So are goroutines, JVM virtual threads, most C/C++ fiber libraries, and Zig's green-thread backend. They differ in who owns the stack and how big it is, not in having one.

The cost? Every task needs a stack, sized for its deepest call. That per-task memory is what lets stackful systems scale to millions of tasks, but not to arbitrarily many.

Option B: don't keep a stack at all. This is “stackless.” Instead of a real stack, the compiler rewrites each suspendable function into a state machine: an object holding just the locals that need to survive the suspension, plus a little integer saying “which resume point are we at.” Suspending returns to the caller and leaves that object behind. Resuming re-enters the function at the recorded spot.

This is used by Rust async, C#, JavaScript, and Zig's (proposed) stackless backend.

The upside is beautiful: a parked task is just that tiny object, with no reserved stack, often only tens of bytes. There's nothing stack-shaped to relocate, which is exactly why it plays nicely with WASM and embedded targets. This is why stackless designs can sit on millions of idle connections without breaking a sweat.

But here's the catch, and it's the whole ballgame: you can only suspend where the compiler rewrote the code. And if that rewrite is selective, covering only the functions you explicitly marked, then you can't yield from deep inside a call chain unless every function in that chain is marked too. Hold that thought. That spreading marker is about to become the second question.

What the difference actually costs you

The memory gap is enormous and worth internalizing. An OS thread pre-commits something like a megabyte of stack; that's the old C10K wall. Userspace tasks start smaller (a Go goroutine opens at a couple of kilobytes and grows on demand), but never at zero. A stackless task, meanwhile, stores only the locals that outlive a suspension, often just a few dozen bytes. That's the difference between “a few hundred thousand connections” and “millions.”

Switching costs, funnily enough, run the other direction, if only mildly. A stackful context switch saves a few registers and swaps the stack pointer; cheap in isolation (tens of nanoseconds), but bouncing between far-apart stacks trashes your cache and TLB locality. A stackless resume is an indirect call into a poll function that branches on a state index and only touches the live fields: cache-friendly, often cheaper per wake, but every suspension pays that dispatch and the optimizer loses some inlining.

And growth is different too. A growable stack has to relocate itself and fix up interior pointers when it overflows, which is exactly why it needs a cooperating runtime to pull off. A stackless design has no stack to grow, but its frames nest, so a deep async chain can balloon into one giant object (Rust folks will recognize the “giant future” problem).

If you want a rule of thumb: stackless wins on per-task memory and connection density; stackful wins when suspension is rare enough that the stack cost amortizes and you'd rather not pay dispatch on every single yield.

So, in one sentence: stackful trades memory for the freedom to suspend anywhere; selective stackless trades that freedom for near-zero memory, and, as we're about to see, buys itself a coloring problem in the process.

Question two: do you have to mark where you suspend?

If you've been around async code for a while, you've probably already read Bob Nystrom's “What Color Is Your Function?”. If you haven't, go read it; it's a classic. The short version: in an async/await world, functions come in two colors.

  • Blue functions (the sync ones) you can call normally.
  • Red functions (the async ones) you can only call with await, and only from inside another red function.

And red is viral. The moment one leaf function goes async (say, your database driver), every single caller above it has to go async too. That's how you end up with a blocking and an async variant of the same library, maintained by different people, plus block_on bridges and friction at every boundary. It's miserable, and it's the single biggest tax that async imposes on an ecosystem.

Now, notice that this is exactly the spreading marker from the stackless section, except now it's surfaced in the type system. The async marker is what lets the compiler rewrite only the functions that suspend instead of all of them. Stackful code has no colors at all: it suspends below the language, down in the runtime, with no compile-time split and no marker to spread.

These two things look like one axis. They are not.

And this is the part I really want you to sit with, because almost everyone gets it wrong. It looks like one axis because the two approaches everyone knows sit neatly on a diagonal: fibers are stackful-and-colorless, async/await is stackless-and-colored. Rust, C#, Kotlin, Python, Swift all weld the two together, using a single async keyword to mean both “this is the stackless mechanism” and “this is the color marker.” So those are the only two corners most people ever see.

But that's a fact about which corners got popular. It is not a fact about the space. The two questions are genuinely independent:

  • Stackful vs. stackless is an implementation question: does a parked task keep a real stack, or get rewritten into a state machine?
  • Colored vs. colorless is a source-language question: does calling a suspendable function need a viral marker, or can you call everything the same way?

Which means you can plot them as two real axes, and when you do, both of the “impossible” corners turn out to be inhabited.

Reading the grid

Put the two questions on two axes and you get a 2x2. Here's the whole space:

Colorless Colored
Stackful goroutines, JVM virtual threads, Zig Io (default), OCaml 5 effects (near-empty, worst of both)
Stackless SML/NJ, Scheme call/cc, Zig Io (Wasm backend, proposed) Rust, C#, Kotlin, Python, Swift

The colorless column is where it gets interesting, because colorlessness shows up over two completely different implementations, and that's your proof that these really are two separate axes and not one. On the stackful side you've got fibers and goroutines, where suspension happens invisibly down in the runtime. On the stackless side you've got whole-program CPS: compile every function to a state machine, and now any call site can suspend with no marker anywhere. That's the “what if await just worked everywhere?” language, and it's been shipping for decades. SML/NJ CPS-converts the entire program (no conventional stack at all); Scheme's call/cc gives you the same colorless suspend-anywhere power. And Zig, as we'll see, manages to sit in both rows of that column at once.

That CPS corner is the one that settles the argument. If uniform stacklessness already gives you colorless suspend-anywhere, then stacklessness plainly does not require coloring. So why on earth do Rust and friends bolt coloring on? Because they want stacklessness to be selective, and free when you don't use it. If every function is a state machine, then every call, including the 99% that never suspend, pays the tax: a heap frame, a resume-point branch, a call the optimizer can't see through. That's perfectly fine for a garbage-collected functional language. It's a non-starter for a systems language that promised you'd pay for what you use. async is the opt-in switch that confines the cost to the functions that actually suspend. So here's the payoff, plainly: coloring is the price you pay for not paying CPS overhead everywhere. (There's a small consolation prize too: await marks the exact spots where another task can interleave, so you can eyeball run-to-completion.)

That explains the whole colored column, and it also explains why the top-right corner, stackful and colored, stays empty. You'd pay for a full stack and viral markers and get nothing for either, since a real stack already suspends anywhere and the marker buys you nothing. The only reason you'd ever go there is to make suspension points auditable in the source, which is basically the effect-system frontier: OCaml 5's handlers are stackful but colorless (effects aren't in the types yet), and type-and-effect languages like Eff would be the ones to finally drag color back in.

So the honest summary is: orthogonal in principle, correlated in practice. All four corners exist. They only line up on the diagonal because the mainstream languages all made the same extra choice, selective stacklessness for performance, and it's that choice, not stacklessness itself, that drags coloring along for the ride.

Which of these corners actually matter for a server? Two of them, really. The blocking box (top-left: fibers and goroutines, straight-line code, you pay in stacks) and the event-loop box (bottom-right: async/await, tiny memory and tight control, you pay in color and the “never block the loop” law) are the models you actually reach for, so they get the deep dives below. The CPS box is a real corner but a language-design curiosity rather than a server architecture, and the empty corner speaks for itself, so neither gets one. And then there's the special kid, Zig's Io, which refuses to sit in any box at all. Let's take them in that order.

The blocking box: stackful + colorless

The pitch here is lovely in its simplicity: just write straight-line code. A read() blocks until the data shows up. You get concurrency by having many contexts, one per task, so blocking one doesn't stall the others. The only real knob is how heavy each context is.

The heavy version is one OS thread per task: one kernel thread per connection, blocking syscalls, the OS scheduler preempts you. The mental model is trivial, blocking is always safe, every library just works, and you get real preemption and parallelism straight from the kernel for free. The problem is that OS threads are fat: big stacks, expensive kernel context switches, scheduler overhead. A few thousand is fine. A million is a fantasy. That's the C10K/C10M wall.

The clever version keeps the exact same programming model but swaps the OS thread for a lightweight userspace context, whether you call it a fiber, a green thread, a goroutine, or a virtual thread. A runtime multiplexes millions of these over a small pool of OS threads. When one hits I/O, the runtime quietly intercepts the call, parks the context, runs something else, and resumes the first one when the kernel says the data's ready.

And here's the trick that trips people up: there's still an event loop under there. It's the runtime's poller. You just never see it, and you never register a callback. It's hidden on purpose.

What you get is genuinely great: no function coloring (every function looks synchronous, blocking is safe anywhere), one uniform ecosystem with no “async version” of every library, and code that reads top-to-bottom like the sequential code it pretends to be.

What you pay is a runtime (a scheduler, growable or segmented stacks, and very often a GC), plus per-task stack memory and a little scheduling cost on every wake. And because the loop is hidden, you lose fine-grained control over batching, core affinity, and tail latency. Preempting CPU-bound tasks is also genuinely hard and very runtime-specific.

This is Go's whole model (goroutines), the JVM's Project Loom (virtual threads), Erlang/BEAM, and most stackful-coroutine libraries. And honestly, for a lot of application-level server code, “just write blocking code and let the runtime sort it out” is exactly the right amount of magic.

The event-loop box: stackless + colored

Now flip the whole thing inside out. A handful of event-loop threads (often one per core) own the readiness queue. The rule for handlers is absolute: never block. When a handler would wait, it registers a continuation and returns to the loop, which goes off to serve other ready connections. The parked work gets picked back up later through that continuation.

Historically that continuation has been spelled three ways, each one sugar over the last: callbacks → futures/promises → async/await. Same machine underneath; better ergonomics each time.

The wins here are real and they're why this model runs the internet's hottest paths: minimal memory per task (no stack, just a little state machine, so millions of connections are cheap), maximal throughput and control (run-to-completion on the loop, no per-request scheduling handoff, you own batching and affinity), and no mandatory runtime or GC. Rust literally compiles this down to a plain state machine, which is why it slots into systems languages and io_uring so naturally, and gives you beautifully predictable tail latency… as long as you obey the one rule.

And the one rule is where all the pain lives. Never block the event loop. One blocking call, one CPU-heavy loop in a handler, and you've frozen every connection sharing that loop: classic head-of-line blocking. On top of that you inherit function coloring (the defining tax of this box), the requirement that your entire stack be non-blocking (a single blocking driver poisons the whole thing), and a genuinely harder mental model where cancellation and lifetimes are famously tricky.

This is Node.js (libuv), Nginx, Netty, Rust's tokio/async-std, Seastar.

The escape hatch, by the way, is exactly what you'd expect: blocking work gets offloaded to a thread pool, which reintroduces a scheduling handoff for that work. Which is the tell. The event-loop box doesn't actually replace the blocking box. It shoves blocking to the edges and keeps the common path fast.

The special kid: Zig's Io

Zig (0.16, April 2026) doesn't pick a box. It attacks the problem from a completely different angle. Instead of baking the model into the language (async keywords, which, funny enough, Zig had and then removed) or into a mandatory runtime (goroutines), it makes I/O an injected interface, the exact same trick Zig already pulls with Allocator.

// I/O is a dependency, constructed once in main() and passed down.
fn fetch(io: std.Io, url: []const u8) !Response { ... }

// Concurrency is a library call on that interface, not a keyword:
var future = io.async(fetch, .{ io, url });
const resp = future.await(io);
future.cancel(io); // cancellation is first-class

Here's the move that makes it all work: the same source runs under any execution model, and which model is decided by whoever constructs the Io back in main(). The standard library ships (or plans) a whole set of backends behind that one vtable:

  • Blocking: direct syscalls, zero overhead, perfect for CLIs and embedded. Shipped.
  • Thread pool: blocking calls multiplexed over OS threads. Shipped.
  • Green threads / stackful coroutines: the current default. The evented flavor (std.Io.Evented, io_uring on Linux, GCD on macOS) has landed but is still experimental as of mid-2026: flagged for testing, with open performance issues and a fairly large per-fiber stack reservation.
  • Stackless coroutines: a state-machine primitive, still just proposed, wanted mostly for targets like Wasm and SPIR-V that have no real stack. Being compiler-level, it can't cross ABI boundaries the way the others can.

So why doesn't this fit in a box? Because it borrows the best of two of them at once. Like the blocking box, the code is colorless: no async in your signatures, and one library works unchanged in blocking and evented mode. Like the event-loop box, it runs on a real non-blocking loop with no mandatory GC and no hidden costs. And unlike either of them, the model is deferred all the way out to the composition root. The same library can drop into a single-threaded embedded target or a millions-of-connections io_uring server, with no rewrite and no color.

That last point is precisely what separates Zig from the CPS languages sitting next door in the same colorless column. They fix one implementation for the whole program. Zig defers it to the caller.

But before you go rewrite your server, the obvious objection: isn't threading io through every function just coloring with extra steps? Partly, yeah, you are passing a parameter through every I/O function. But it's one uniform, non-viral parameter (exactly like Allocator), not a red/blue split with incompatible calling rules. You can still call it from anywhere, and there's no parallel “sync universe” and “async universe” of your ecosystem to maintain. That's the whole difference, and it's the thing that makes the colorlessness real rather than cosmetic.

There are real costs and sharp edges here too, from vtable dispatch to the footguns around expressing concurrency, but they're covered far better than I could by the people building this. Go read Loris Cro's and Andrew Kelley's posts (linked below) for the downsides straight from the source.

The taxonomy at a glance

If you want the whole thing on one screen:

Context per task Coloring Runtime/GC Event loop Memory at scale Blocking calls
OS threads kernel thread none no hidden (kernel) poor safe
Fibers/goroutines userspace stack none scheduler; often GC hidden good safe
Async/await (event loop) state machine viral no (Rust) explicit best forbidden on loop
Zig Io backend's choice uniform param no pluggable backend-dependent safe if backend allows

The real question

Here's what I want you to take away. The question was never “blocking vs. non-blocking”; under the hood, everyone is polling an event loop. The real question is where the decision to yield lives, and who foots the bill:

  • In the blocking box, yielding hides inside a runtime. You pay in stacks, a scheduler, and usually a GC, and you give up direct control of the loop.
  • In the event-loop box, yielding lives in the type system, spelled async. You pay in color and a stack-wide async contract, and in return you get memory efficiency and control.
  • In Zig's Io, yielding lives in an injected interface. You pay in a threaded parameter and some vtable dispatch, and in return you get to defer the entire choice to the edge of your program.

None of these is universally better; each one optimizes for a different thing. The blocking box optimizes for developer sanity, the event-loop box for machine efficiency at scale, and Zig for keeping both doors open in a single codebase. Pick based on what your handlers actually do: if they mostly wait, lean toward an event loop (or Zig configured as one); if they mostly do blocking work, the blocking box wins on clarity. And ask yourself the honest question underneath all of it: can you even afford a runtime in the first place?