The Book·Chapter 20·14 min

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

src/main.rs
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

src/lib.rs
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:

  1. Drop the sender. This is what closes the channel. Once dropped, recv() in each worker returns Err, the workers break out of their loops.
  2. 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

src/main.rs
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::bind and incoming() — sync sockets in std::net.
  • Closures as first-class values — passed to pool.execute, boxed, sent across threads.
  • Trait objectsBox<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 Drop trait — 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:

  • tokio for the async runtime
  • axum, actix-web, or tonic for the HTTP/gRPC layer
  • tower for middleware
  • hyper as 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 systemsSail, 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.
  • Compilersrustc itself, or rust-analyzer, or swc.

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.