Skip to main content

Async I_O

Async I/O means:

Performing input/output without blocking the thread while waiting for the OS.

Instead of:

  • Waiting for data
  • Parking the thread

We:

  • Ask the OS to notify us when data is ready
  • Yield control
  • Resume later

This is the foundation of scalable async systems.

Why blocking I/O does not scale

Blocking I/O (classic model)

let n = socket.read(&mut buf); // blocks thread

Problems:

  • One blocked thread per connection
  • Thousands of connections = thousands of threads
  • High memory + context switch overhead

Async I/O model

let n = socket.read(&mut buf).await; // yields

Benefits:

  • One thread can manage thousands of sockets
  • No wasted threads
  • Predictable latency

How async I/O works at the OS level

Rust does not magically make I/O async.

It relies on OS facilities:

OSMechanism
Linuxepoll
macOSkqueue
WindowsIOCP
  1. Register socket with OS
  2. Ask: “tell me when readable/writable”
  3. OS blocks internally
  4. OS sends readiness event
  5. Runtime wakes the future

The thread is never blocked waiting for I/O.

Async I/O in Rust (API level)

Rust uses: AsyncRead , AsyncWrite

Traits (simplified):

trait AsyncRead {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<()>;
}

They follow the same Future polling rules:

  • Return Pending if not ready
  • Register waker
  • Return Ready when data is available

Tokio async TCP example (server)

Cargo.toml

[dependencies]
tokio = { version = "1", features = ["full"] }

Async TCP echo server

use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

async fn handle_client(mut socket: TcpStream) {
let mut buf = [0; 1024];

loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
break; // client disconnected
}

socket.write_all(&buf[..n]).await.unwrap();
}
}

#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();

loop {
let (socket, _) = listener.accept().await.unwrap();
tokio::spawn(handle_client(socket));
}
}

What happens step-by-step (deep explanation)

listener.accept().await

  1. Registers socket with reactor
  2. If no client → returns Pending
  3. OS waits for connection
  4. OS notifies runtime
  5. Runtime wakes task
  6. Accept resumes

socket.read().await

  1. Tries reading from socket
  2. If no data → Pending
  3. Registers waker
  4. OS waits for data
  5. OS signals readiness
  6. Task resumes
  7. Read completes

Multiple clients

  • Each client = one task
  • Tasks are lightweight
  • Thousands of connections per thread

Non-blocking file I/O (important caveat)

Files are tricky

  • Most OSes do not support async file IO well
  • Tokio uses blocking threads under the hood
use tokio::fs;

let content = fs::read_to_string("file.txt").await.unwrap();

This is async from your POV, but internally:

  • Offloaded to a thread pool
  • Avoids blocking executor threads

Timers are also async I/O

use tokio::time::{sleep, Duration};

sleep(Duration::from_secs(1)).await;

Internally:

  • Timer wheel / heap
  • OS timer
  • Waker triggered on timeout

What async I/O is NOT

  • Parallel CPU work
  • Faster computation
  • Thread replacement

Async I/O is about waiting efficiently.

Common mistakes

Blocking calls in async code

std::fs::read("file.txt"); // blocks runtime

Holding locks across .await

let guard = mutex.lock().unwrap();
do_async().await; // DEADLOCK risk

Mixing runtimes

Tokio socket ≠ async-std socket.

Mental model to keep forever

Think of async I/O as:

“Register interest → yield → resume on readiness.”

Or:

“Don’t wait. Ask to be notified.”