DEV Community

이관호(Gwanho LEE)
이관호(Gwanho LEE)

Posted on

Understanding Async Socket Handling in Rust: From TCP Request to Waker Wake-up

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
Enter fullscreen mode Exit fullscreen mode

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?;
Enter fullscreen mode Exit fullscreen mode

This sets up a listening socket. When a client connects:

let (socket, addr) = listener.accept().await;
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

✅ What happens under the hood:

When .read().await is called:

  1. It creates a Future that implements the Future trait with a .poll() method
  2. The async runtime (e.g., Tokio) calls .poll() on this future
  3. 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
    1. The task is paused (not executed again until woken up)

Later:

  • The Reactor calls epoll_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() returns Poll::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();
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

✅ 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() returns Pending, 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)