4 min readBy Shivraj Soni

How Asynchronous I/O Works Internally in Rust

It's not magic—it's a 3-layer dance between the OS, the language, and a runtime. Here's how it actually works.

OSRustTechnical

Async in Rust: Reactor, Future, and Executor

Rust’s async isn’t a black box. Under the hood it’s a three-layer system: the OS tells you when I/O is ready, the language gives you futures (state machines), and a runtime keeps polling until things are done. Once you see how those pieces fit together, a lot of the “how does this even work?” feeling goes away.


The Reactor: Let the OS Do the Waiting

At the bottom, the operating system already knows how to watch a bunch of I/O sources at once. The old, naive way is to sit in a loop and keep asking: “Hey socket, got data yet? No? How about now?” That burns CPU and doesn’t scale.

The smarter approach: hand the OS a list of sockets (or file descriptors) and say, “Wake me when any of these is ready.” One thread can then drive thousands of connections. On Linux that’s epoll; on macOS/BSD it’s kqueue; on Windows it’s IOCP (Proactor-style). The idea is the same: the OS does the waiting, and your code only runs when there’s actual work.

So the flow looks like: register your sockets with the reactor, call something like epoll_wait(), and the runtime sleeps until the OS says “this one’s ready.” Then you dispatch to the right handler. No busy-looping. That’s how a single thread can handle tens of thousands of connections without melting.


Futures: What Your async fn Really Is

When you write:

async fn fetch_data() -> String {
    "Hello".to_string()
}

the compiler turns that into a state machine. It doesn’t run the function to completion in one go; it produces a value (a Future) that can be polled. Each time you call poll(), the future does a bit of work and either returns “I’m done, here’s the result” or “I’m still waiting (e.g. on I/O), ask me again later.” So “async” in Rust is really: your function becomes a lazy, resumable computation that the runtime drives by repeatedly calling poll().


The Executor: What Actually Runs the Futures

The reactor knows when I/O is ready. Futures describe what to do. But something has to actually run the futures—call poll(), and when they say “pending,” put them aside until their I/O is ready. That something is the executor.

You can think of it as the event loop. It holds a queue of tasks (futures), picks one, calls poll(). If the future returns “Ready,” the task is done. If it returns “Pending,” the future typically gives the executor a Waker: a callback that means “when my I/O is ready, put me back in the runnable queue.” The executor then parks that task and moves on. When there’s nothing runnable, it goes to sleep by calling into the OS (e.g. epoll_wait()). When the OS says “this socket has data,” the right Waker is called, the task goes back on the queue, and the loop continues. No thread per task, no busy-waiting—just a single loop that drives everything.

A simple mental model: a teacher (executor) walking around the class (futures). They ask each student “done with your homework?” If yes, they collect it. If not, the student says “I’ll raise my hand when I’m ready.” The teacher doesn’t stand there staring; they go do something else or take a nap until someone raises their hand. That’s the same idea: the executor only spends CPU on tasks that can make progress, and the OS tells it when I/O is ready.

So in short: the reactor (OS) handles “when is I/O ready?” The future (Rust) is “what computation to run.” The executor is “run the futures, and when they’re waiting on I/O, sleep until the reactor says it’s ready.” Together they give you async I/O that scales.


// Pseudo-code of an Executor loop
loop {
    if let Some(task) = runnable_queue.pop() {
        match task.poll() {
            Ready(val) => println!("Task finished: {:?}", val),
            Pending => park_task(task), // waits for Waker
        }
    } else {
        os_reactor_wait();
    }
}

Hope this makes the internals a bit less mysterious. If you want to go deeper, the next step is to read how Wakers are created and how they hook into the reactor—that’s where the “wake me when this socket is ready” wiring lives.