Telex logo Telex

Building a Chat Server in Rust #6: Going Async

Post 6 of 6 in Building a Chat Server in Rust. Companion series: Rust Patterns That Matter.

Previous: #5: Going Multi-threaded

Last time we went multi-threaded - one OS thread per client. It works, but threads are expensive: each one costs ~8KB of stack, and context switching adds up. For thousands of concurrent connections, we want async: lightweight tasks on a small thread pool.

This post replaces std::thread with tokio and runs into two final patterns. The code is on the 06-async branch.

The migration

Most of the changes are mechanical: std::net::TcpListener becomes tokio::net::TcpListener, std::sync::Mutex becomes tokio::sync::Mutex, thread::spawn becomes tokio::spawn, and methods gain async and .await. But two things require real understanding.

Pattern #21: Pin + Boxing Futures

In Stage 4, our filter system used Box<dyn FnMut(...)>. In async, even simple filters need to .await - our counting filter acquires a tokio::Mutex, which requires .await. The moment a filter does any async work, its return type must be a Future. But async methods return futures with anonymous types - you can't name them.

The solution: return a Pin<Box<dyn Future>>:

pub trait AsyncFilter: Send + Sync {
    fn apply<'a>(
        &'a self,
        username: &'a str,
        body: &'a str,
    ) -> Pin<Box<dyn Future<Output = FilterAction> + Send + 'a>>;
}

Three layers, each solving a specific problem:

Implementing the trait uses Box::pin(async move { ... }):

impl AsyncFilter for CountingFilter {
    fn apply<'a>(
        &'a self,
        _username: &'a str,
        _body: &'a str,
    ) -> Pin<Box<dyn Future<Output = FilterAction> + Send + 'a>> {
        Box::pin(async move {
            let mut count = self.count.lock().await;
            *count += 1;
            println!("[filter] message #{} processed", *count);
            FilterAction::Allow
        })
    }
}

Box::pin() is the incantation: box the future (erase its type), pin it (prevent moves). The caller can .await it like any other future.

Deep dive: Rust Patterns #21: Pin and Boxing Futures covers why Pin exists and when you need it.

Pattern #22: Send / Sync in Async

tokio::spawn requires the future to be Send. That means everything the future holds across an .await point must be Send. If it isn't, you get:

// error: future cannot be sent between threads safely
// the trait `Send` is not implemented for `Rc<...>`

This is why every step of our evolution mattered: Rc -> Arc (Arc is Send + Sync), RefCell -> Mutex (Mutex is Send + Sync), Box<dyn FnMut> -> Box<dyn FnMut + Send>.

The AsyncFilter trait requires Send + Sync:

pub trait AsyncFilter: Send + Sync { /* ... */ }

Send: the filter can be moved to another thread (needed because tokio may run tasks on any thread). Sync: the filter can be shared between threads via & reference (needed because multiple tasks may exist concurrently).

The returned future also needs + Send:

// The future must be Send so it can be .awaited inside tokio::spawn.
-> Pin<Box<dyn Future<Output = FilterAction> + Send + 'a>>

The pattern: in async Rust, everything that crosses an .await boundary inside tokio::spawn must be Send. If the compiler complains, the fix is usually one of: add + Send to trait bounds, drop non-Send values before the .await, or restructure to avoid holding them across the yield point.

Deep dive: Rust Patterns #22: Send / Sync in Async covers the full story including spawn_local as an escape hatch.

The main loop

#[tokio::main]
async fn main() -> Result<(), ChatError> {
    let server = Arc::new(Mutex::new(server));
    let listener = TcpListener::bind(&addr).await?;

    loop {
        let (stream, _) = listener.accept().await?;
        let server = Arc::clone(&server);

        tokio::spawn(async move {
            if let Err(e) = handle_client(server, stream).await {
                println!("Client error: {e}");
            }
        });
    }
}

The structure is almost identical to the threaded version. The difference is invisible: each tokio::spawn creates a lightweight task (<1KB) instead of an OS thread (~8KB). Thousands of concurrent connections, a handful of threads.

Try it

# Terminal 1
git checkout 06-async
cargo run

# Terminal 2 and 3 (simultaneously)
nc 127.0.0.1 8080
alice
hello!                         # → bob sees: <alice> hello!

The full journey

Six posts, six branches, 22 patterns:

  1. Hello, TCP - Newtype, From/Into, Error Handling
  2. Rooms and Users - RefCell, Rc, Rc<RefCell>, Split Borrows, Index-Based Design, Drop/RAII
  3. Parsing and Performance - Lifetime Annotations, Cow, Custom Iterators, 'static+Clone
  4. Commands and Plugins - Enum Dispatch, Fn/FnMut/FnOnce, Storing Closures, Builder, Typestate
  5. Going Multi-threaded - Arc<Mutex>, Channels
  6. Going Async - Pin+Boxing Futures, Send/Sync in Async

Every pattern was motivated by a real problem in a real project. The companion series - Rust Patterns That Matter - covers each pattern in depth.