Final project — a multithreaded web server
TcpListener, a thread pool, graceful shutdown, and what to read next
The classic finale of "The Rust Programming Language" book. The exercise is small but pulls together the whole stack: ownership, traits, channels, Drop, trait objects. Real production code would not look like this. Real production code would use axum or tonic. But typing this out by hand teaches what those frameworks hide.
Phase one: single-threaded server
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").expect("bind");
for stream in listener.incoming() {
let stream = stream.expect("accept");
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let reader = BufReader::new(&mut stream);
let request_line = reader.lines().next().unwrap().unwrap();
let (status, body) = if request_line.starts_with("GET / ") {
("HTTP/1.1 200 OK", "<h1>hello</h1>")
} else {
("HTTP/1.1 404 NOT FOUND", "<h1>not found</h1>")
};
let response = format!(
"{status}\r\nContent-Length: {len}\r\n\r\n{body}",
len = body.len(),
);
stream.write_all(response.as_bytes()).unwrap();
}That works. Run cargo run, hit http://127.0.0.1:7878/, get HTML back. One connection at a time, blocking everyone else while it processes. Fine for the exercise, not for anything else.
Phase two: spawn-per-connection
The easiest concurrency model: a new OS thread per incoming connection.
use std::thread;
for stream in listener.incoming() {
let stream = stream.expect("accept");
thread::spawn(move || handle_connection(stream));
}This works but has no upper bound. Ten thousand concurrent clients spawn ten thousand OS threads, each with its own stack. The system crashes. Production code uses a thread pool with a fixed worker count.
Phase three: a thread pool
The interface we want:
let pool = ThreadPool::new(4);
for stream in listener.incoming() {
let stream = stream.expect("accept");
pool.execute(move || handle_connection(stream));
}Four worker threads share an mpsc channel. The pool's execute sends a job on the channel; whichever worker is free picks it up.
The job type:
type Job = Box<dyn FnOnce() + Send + 'static>;A heap-allocated, one-shot closure that can be transferred to another thread. Each part of that signature earns its keep:
Box<dyn ...>— the closure has unknown concrete type; box it for storage.FnOnce()— runs once and consumes its captures.Send— transferred to a worker thread.'static— outlives the borrow lifetime of the call site.
The pool itself
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
type Job = Box<dyn FnOnce() + Send + 'static>;
pub struct ThreadPool {
workers: Vec<Worker>,
sender: Option<mpsc::Sender<Job>>,
}
impl ThreadPool {
pub fn new(size: usize) -> Self {
assert!(size > 0);
let (sender, receiver) = mpsc::channel::<Job>();
let receiver = Arc::new(Mutex::new(receiver));
let workers = (0..size)
.map(|id| Worker::new(id, Arc::clone(&receiver)))
.collect();
ThreadPool { workers, sender: Some(sender) }
}
pub fn execute<F>(&self, job: F)
where
F: FnOnce() + Send + 'static,
{
let job: Job = Box::new(job);
self.sender
.as_ref()
.expect("sender dropped")
.send(job)
.expect("worker channel closed");
}
}
struct Worker {
id: usize,
handle: Option<thread::JoinHandle<()>>,
}
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Self {
let handle = thread::spawn(move || loop {
let next = receiver.lock().expect("poisoned").recv();
match next {
Ok(job) => {
println!("worker {id}: running job");
job();
}
Err(_) => {
println!("worker {id}: channel closed, exiting");
break;
}
}
});
Worker { id, handle: Some(handle) }
}
}Some details worth unpacking.
The receiver is wrapped in Arc<Mutex<...>> because mpsc::Receiver is single-consumer; multiple workers cannot own it. The Mutex serializes their access. Each worker locks briefly to call recv(), then releases. Production code would use crossbeam_channel or flume, which support multiple consumers natively without locking.
The sender field is wrapped in Option<...>. That is because graceful shutdown requires dropping the sender to close the channel, and we cannot move out of &mut self cleanly. Option::take() is the standard trick.
Phase four: graceful shutdown
impl Drop for ThreadPool {
fn drop(&mut self) {
drop(self.sender.take()); // close the channel
for worker in &mut self.workers {
println!("joining worker {}", worker.id);
if let Some(handle) = worker.handle.take() {
handle.join().expect("worker panicked");
}
}
}
}Order of operations:
- Drop the sender. This is what closes the channel. Once dropped,
recv()in each worker returnsErr, the workers break out of their loops. - Join each worker. Wait for the worker thread to finish its loop and exit cleanly.
Without this Drop impl, the pool would leak threads on shutdown. With it, you get a clean exit: any in-flight job completes, no new jobs are accepted, every worker thread is joined.
The Worker::handle is Option<JoinHandle<()>> for the same reason: join() consumes the handle, and consuming through &mut self requires Option::take().
Updated main
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use web_server::ThreadPool;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").expect("bind");
let pool = ThreadPool::new(4);
for stream in listener.incoming().take(2) { // for the test, exit after 2
let stream = stream.expect("accept");
pool.execute(move || handle_connection(stream));
}
println!("shutting down");
}When main returns, pool goes out of scope, Drop runs, the channel closes, the workers exit, the program ends. RAII handles the cleanup.
What this taught
Walk back through the moving parts:
TcpListener::bindandincoming()— sync sockets instd::net.- Closures as first-class values — passed to
pool.execute, boxed, sent across threads. - Trait objects —
Box<dyn FnOnce() + Send + 'static>is a runtime-polymorphic value. Arc<Mutex<T>>— shared mutable state across threads. Here, the receiver.mpsc::channel— multi-producer, single-consumer queue. Here, repurposed as a work queue.Option::take()— the move-out-of-mutable-reference trick.- The
Droptrait — graceful shutdown by piggybacking on scope exit.
Every concept in chapters 13 through 19 shows up here.
Why production code does not look like this
Modern Rust web services use:
tokiofor the async runtimeaxum,actix-web, ortonicfor the HTTP/gRPC layertowerfor middlewarehyperas the underlying HTTP engine
A real handler is async fn handle(State(state): State<AppState>, Json(req): Json<Req>) -> Result<Json<Res>, AppError>. The thread pool is hidden. Connections are async tasks, not OS threads. The numbers are wildly better: a single Tokio worker can serve tens of thousands of concurrent connections, where a thread-per-connection model dies in the low thousands.
But the framework hides the same primitives you just typed out: ownership, trait objects, RAII, channels. The book exercise is what they are made of.
Where to go from here
You finished a Rust book. The next step is reading real code.
Pick a codebase that matches your interests:
- Query engine and data systems — Sail, DataFusion, Polars, Arrow.
- Async runtimes and networking — Tokio, Hyper, Axum, Tonic.
- CLI tools — ripgrep, fd, bat, eza. Small, polished, idiomatic.
- Embedded and systems — Redox, the various RTIC projects.
- Compilers —
rustcitself, orrust-analyzer, orswc.
Read pull requests, not just the code at HEAD. PRs show how the codebase evolves: what gets pushed back, what gets accepted, what conventions hold.
Apply the orchestrator's review lens to every diff you send through an agent. Walk back through the failure modes and review lens callouts in this book. The list of things to watch for does not change much from project to project; the shape of agent-written Rust converges on the same handful of mistakes.
That is the working theory. The book is over. The practice starts.