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.

0
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