Ditching async/await: A Look at Low-Level I/O with Mio

Khushal AgrawalKhushal Agrawal
4 min read

Ever wondered how high-performance servers like Redis handle thousands of connections on a single thread? The secret lies in a low-level event loop, and in Rust, the tool for that job is Mio.

Mio is a fast, low-level I/O library for Rust that focuses on non-blocking APIs and event notification. It’s built for developing high-performance I/O applications with minimal overhead over the operating system’s native abstractions. Think of it as a very, very barebones version of Tokio, without any of the async/await ergonomics.

I came across Mio while trying to build an event-based loop for my own Redis-like key-value store in Rust. Redis is famously single-threaded yet incredibly performant, thanks to its event loop architecture. To mimic that behavior, I needed a low-level, fine-grained way to handle I/O — and Mio was the perfect fit.

Features

  • Non-blocking I/O: Supports non-blocking reads and writes for TCP and UDP.

  • Event notification: Wraps epoll (Linux), kqueue (macOS), IOCP (Windows) in a uniform API.

  • Minimal abstraction: Gives you raw access with zero-cost abstractions — no thread pools, tasks, or futures.

  • Portable: Works across Unix and Windows systems with the same API.

  • Foundation for async: Tokio, for example, is built on top of Mio.

Use cases

  • Building TCP and UDP servers: Ideal for high-performance networking applications.

  • Implementing an event loop: Like in Redis, allowing thousands of concurrent connections with a single thread.

  • Creating custom async runtimes: If you want to experiment with your own concurrency models or learn how things work under the hood.

  • Learning systems-level I/O: Great for understanding how event-driven systems work at the OS level.

Limitations

  • No async/await support: You’ll need to manage all state and I/O readiness manually.

  • Low-level and verbose: Requires more boilerplate than higher-level frameworks.

  • No built-in runtime or task scheduling: You write everything yourself.

Here’s what a minimal sketch of how a Mio-based TCP server looks like:

use mio::{Events, Interest, Poll, Token};
use mio::net::TcpListener;
use std::collections::HashMap;

fn main() -> std::io::Result<()> {
    let mut poll = Poll::new()?;
    let mut events = Events::with_capacity(128);

    let addr = "127.0.0.1:9000".parse().unwrap();
    let mut server = TcpListener::bind(addr)?;

    // Use Token(0) for the server
    const SERVER: Token = Token(0);
    poll.registry().register(&mut server, SERVER, Interest::READABLE)?;

    // A simple counter for generating unique tokens for clients
    let mut unique_token_counter = 1;

    loop {
        poll.poll(&mut events, None)?;
        for event in &events {
            match event.token() {
                SERVER => loop {
                    // The server is ready to accept new connections.
                    // We use a loop because we might receive multiple connections at once.
                    match server.accept() {
                        Ok((mut connection, address)) => {
                            println!("Accepted new connection from: {}", address);

                            // Generate a new token for the client
                            let client_token = Token(unique_token_counter);
                            unique_token_counter += 1;

                            // **Register the new connection with Poll**
                            poll.registry().register(
                                &mut connection,
                                client_token,
                                Interest::READABLE.add(Interest::WRITABLE),
                            )?;
                        }
                        // If we get a "WouldBlock" error, we've accepted all pending connections.
                        Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                            break; 
                        }
                        Err(e) => {
                            return Err(e); // A real error occurred
                        }
                    }
                },
                token => {
                    // This is where you would handle events for a specific client,
                    // like reading their sent data.
                    println!("Got event for client token: {:?}", token);
                }
            }
        }
    }
}

So, when should you reach for mio ?

Mio is for the builder who needs to get their hands on the raw engine. While frameworks like Tokio provide a comfortable, powerful car with automatic transmission (async/await), Mio gives you the gearbox, the pistons, and the chassis. You reach for it when:

  • You need absolute, fine-grained control over the event loop.

  • You're building a high-performance system, like a database or network proxy, where every nanosecond of overhead counts.

  • You want to build your own async runtime or understand how they work under the hood.

  • Or you just don’t like things done for you and want to do it yourself!

In my journey to build a Redis-like server, Mio was the perfect choice because it provides the fundamental, OS-level event notifications without imposing any other opinions. It's not the easiest path, but it's the one that gets you closest to the metal.

10
Subscribe to my newsletter

Read articles from Khushal Agrawal directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Khushal Agrawal
Khushal Agrawal