A deep dive into how a client request flows from the OS into a Rust async server using TCP, sockets, file descriptors, polling, and wakers.
🚀 Introduction
When I first started studying Rust async programming, I was fascinated and confused by how it worked under the hood. What really happens when a client connects? How does .await
actually pause and resume tasks? How do the OS, TCP, sockets, threads, and async tasks interact?
In this post, I’ll walk you through everything I’ve learned about how a TCP connection flows from a client, through the operating system, into a Rust async program, and how .await
uses wakers and polling to manage thousands of concurrent connections efficiently.
🌐 Step 1: The Client Sends a Request
When a client (e.g. browser or mobile app) sends a request like:
GET /api/user HTTP/1.1
Host: 192.168.1.10:8080
The client opens a TCP connection to your server’s IP address and port. This happens at the operating system level, which manages network communication.
💻 Step 2: The OS Routes the TCP Packet
The server OS listens on port 8080
, and it knows which program (process) registered interest in that port. When the TCP packet arrives, the OS routes it to the corresponding server process (your Rust program).
🧱 Step 3: Rust Program Accepts the Connection
When your server runs, it registers interest in a port using:
let listener = TcpListener::bind("0.0.0.0:8080").await?;
This sets up a listening socket. When a client connects:
let (socket, addr) = listener.accept().await;
A new socket is created (represented by a file descriptor, or fd
), which uniquely identifies that connection. This happens without creating a new OS thread or process.
⚙️ Step 4: Spawning an Async Task
Once the connection is accepted, you typically handle it using an async task:
tokio::spawn(async move {
handle_client(socket).await;
});
This is not a thread — it’s a lightweight future scheduled by the Tokio async runtime.
📥 Step 5: Reading From the Socket (Await)
Inside the handler:
async fn handle_client(mut socket: TcpStream) {
let mut buf = [0u8; 1024];
let n = socket.read(&mut buf).await.unwrap();
println!("Read {} bytes", n);
}
✅ What happens under the hood:
When .read().await
is called:
- It creates a Future that implements the
Future
trait with a.poll()
method - The async runtime (e.g., Tokio) calls
.poll()
on this future - If the socket is not ready to read, the
.poll()
implementation:
- Returns
Poll::Pending
- Calls
Reactor::register_fd()
to register the socket FD with epoll - Stores the
Waker
to be notified later- The task is paused (not executed again until woken up)
Later:
- The
Reactor
callsepoll_wait()
in a loop - When the socket becomes readable, the OS notifies the reactor
- The stored
Waker
is called →waker.wake()
- The task is placed back in the async runtime’s queue and is polled again
- This time,
.poll()
returnsPoll::Ready
and data is read
So .read().await
leads to .poll()
being called repeatedly, pausing and resuming the task around socket readiness.
📡 Step 6: Reactor + Epoll Wait
Here's what the simplified Reactor structure looks like:
use std::os::unix::io::RawFd;
use std::sync::{Arc, Mutex};
use std::collections::HashMap;
use std::task::Waker;
use libc::{epoll_create1, epoll_ctl, epoll_event, epoll_wait, EPOLLIN, EPOLL_CTL_ADD};
pub struct Reactor {
pub registry: Mutex<HashMap<RawFd, Waker>>,
pub epoll_fd: RawFd,
}
impl Reactor {
pub fn new() -> Self {
let epfd = unsafe { epoll_create1(0) };
Reactor {
registry: Mutex::new(HashMap::new()),
epoll_fd: epfd,
}
}
pub fn register_fd(&self, fd: RawFd, waker: Waker) {
unsafe {
let mut event = epoll_event {
events: EPOLLIN as u32,
u64: fd as u64,
};
epoll_ctl(self.epoll_fd, EPOLL_CTL_ADD, fd, &mut event);
}
self.registry.lock().unwrap().insert(fd, waker);
}
pub fn poll(&self) {
let mut events: [epoll_event; 10] = unsafe { std::mem::zeroed() };
let ready = unsafe { epoll_wait(self.epoll_fd, events.as_mut_ptr(), 10, 0) };
for i in 0..ready as usize {
let fd = events[i].u64 as RawFd;
if let Some(waker) = self.registry.lock().unwrap().remove(&fd) {
waker.wake();
}
}
}
}
This is the core of how Rust async runtimes like Tokio interface with the OS. It keeps everything efficient and non-blocking.
🧠 Summary of the Flow
[Client] ──TCP Connect──▶ [OS Port 8080]
▼
[Rust Program (1 process)]
▼
listener.accept().await → fd
▼
tokio::spawn(async move { read().await })
▼
read().await → .poll() → Pending
▼
Reactor registers fd + stores waker
▼
epoll_wait() detects fd readable → waker.wake()
▼
Task resumes and reads data
✅ Key Takeaways
- The Rust program is a single OS process
- Client connections are handled using sockets (FDs)
- Async tasks are lightweight and do not use threads unless needed
-
.await
is syntactic sugar for.poll()
on futures - If
.poll()
returnsPending
, the reactor stores the waker - The OS notifies via epoll when the socket is ready
- The waker resumes the paused task to continue processing
🔚 Final Thoughts
When I first encountered .await
, I thought it was magic. But now I understand that it's just a clever system of polling, scheduling, and wakers — all orchestrated by the runtime and deeply integrated with the OS's epoll system.
This post reflects my deepened understanding and readiness to build high-performance, async Rust applications. If you're an interviewer or a fellow engineer, I hope this breakdown shows my passion for systems thinking, precision, and clear architectural reasoning.
Written as part of my journey to become a Rust system programmer. Feel free to use or share this if you’re on the same path.
Top comments (0)