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:
- Future - the async computation. Every
async fnreturns one, but its concrete type is unnameable. - Box - heap-allocate to erase the concrete type. Different
implementations can return different future types, but they all look like
Box<dyn Future>. - Pin - guarantee the future won't move in memory. Async
state machines can contain self-references (a reference to a local variable
that's part of the state machine). Moving would invalidate those references.
Pinprevents that.
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:
- Hello, TCP - Newtype, From/Into, Error Handling
- Rooms and Users - RefCell, Rc, Rc<RefCell>, Split Borrows, Index-Based Design, Drop/RAII
- Parsing and Performance - Lifetime Annotations, Cow, Custom Iterators, 'static+Clone
- Commands and Plugins - Enum Dispatch, Fn/FnMut/FnOnce, Storing Closures, Builder, Typestate
- Going Multi-threaded - Arc<Mutex>, Channels
- 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.
Telex