Hedronite Lesson · Polyglot-Dev / Rust · Mon 2026-10-05

Final project: building a multithreaded web server

Three stages: a single-threaded HTTP loop, a fixed ThreadPool, then Drop that stops workers cleanly.

Lesson Class: Duha (Rust language track · Beat A · topic T1)
Focus: TcpListener HTTP · ThreadPool mpsc + ArcMutex · Drop shutdown
Code Blocks: clean blocks from TRPL 21-1 bind, request/response, ThreadPool new/execute, Drop
Done-criteria: Can walk the book's single-thread then thread-pool server
Grounding: TRPL stable ch21-00 through ch21-03 · Topics #24 closes TRPL · no async runtime in chapter
The Single-Threaded Server
TcpListener accepts; read a request line; write an HTTP/1.1 response with Content-Length.
The Thread Pool
Fixed workers pull jobs from mpsc through ArcMutexReceiver. Job is Box dyn FnOnce Send static.
Graceful Shutdown
Drop takes the sender so recv errs, then joins each worker thread.
Three stages: a single-threaded HTTP loop, a fixed ThreadPool, then Drop that stops workers cleanly.

<!-- hal:authoritative:yaml -->

*Three stages: a single-threaded HTTP loop, a fixed ThreadPool, then Drop that stops workers cleanly.*

§I - Frame

Duha session 24. Topics #24, TRPL Chapter 21: the book's final project. The overview (ch21-00) sets the plan: a little TCP and HTTP, a listening socket, a few request lines, a proper response, then a thread pool for throughput. The book says this is teaching code, not a production crate, and it does not build an async runtime here.

Session 23 shipped unsafe and macros. This row does not re-teach them. Channels and Mutex from Ch.16 return as the pool's wiring.

Three moves land by the end:

  1. The Single-Threaded Server: TcpListener accepts a stream; read one request line; write an HTTP/1.1 response.
  2. The Thread Pool: a fixed set of workers pull jobs from mpsc through Arc<Mutex<Receiver>>.
  3. Graceful Shutdown: Drop for ThreadPool drops the sender, then joins every worker.

Done-criteria: Can walk the book's single-thread then thread-pool server (trace a request to a response, name why the pool uses mpsc + Arc<Mutex<Receiver>>, and say what Drop does at shutdown).

§II - The Single-Threaded Server

Bind a listener and walk incoming() (Listing 21-1 shape):

use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();
        println!("Connection established!");
    }
}

Each accepted TcpStream is one TCP connection. HTTP rides on that stream as text. The first line is the request line. A BufReader yields lines until the blank line that ends the headers:

use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND", "404.html")
    };

    let contents = std::fs::read_to_string(filename).unwrap();
    let length = contents.len();
    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

The status line, headers, a blank line (\r\n\r\n), then the body: that is the response shape the book teaches. Content-Length matches the body bytes. Matching on the request line chooses hello.html or 404.html. A slow path such as GET /sleep that sleeps five seconds shows the limit of one thread: while that handler runs, no other connection is served. The accept loop waits on that one call. Throughput collapses under concurrent clients even though the TCP listen backlog still fills.

§III - The Thread Pool

Spawning a new thread per connection works until the machine runs out of threads. The book replaces unbounded thread::spawn with a pool of fixed size. main becomes:

let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
let pool = ThreadPool::new(4);

for stream in listener.incoming() {
    let stream = stream.unwrap();
    pool.execute(|| {
        handle_connection(stream);
    });
}

ThreadPool::new builds a channel and wraps the receiver so every worker can share it:

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

type Job = Box<dyn FnOnce() + Send + 'static>;

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);
        let (sender, receiver) = mpsc::channel();
        let receiver = Arc::new(Mutex::new(receiver));
        let mut workers = Vec::with_capacity(size);
        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }
        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        self.sender.send(Box::new(f)).unwrap();
    }
}

Why that type stack: mpsc moves jobs from execute to workers. One Receiver cannot be cloned, so Arc shares ownership and Mutex lets one worker lock, recv, and unlock before the next. Job is a boxed FnOnce so each closure can run once on a worker thread. Send + 'static is the bound the book needs so the closure can cross threads and outlive execute. A worker loops on recv, prints that it got a job, and calls it. Four workers and a shared queue replace one-thread-per-connection without opening an unbounded spawn.

§IV - Graceful Shutdown

When main ends, the pool must stop. The book implements Drop for ThreadPool. First the sender is dropped so recv returns Err on every worker. Then each worker's thread is joined:

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);
            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

sender is stored as Option<mpsc::Sender<Job>> so take can move it out and drop it. Worker.thread is Option<JoinHandle<()>> for the same reason: join needs ownership. Inside the worker loop, Ok(job) runs the closure; Err(_) means the channel closed, so the worker prints disconnect and breaks. Limiting incoming().take(2) in main is the book's demo so shutdown is visible after two requests.

§V - Proof and close

  1. Name the three stages: single-threaded HTTP, thread pool, graceful shutdown.
  2. Point at TcpListener::bind → request line → write_all of an HTTP/1.1 response.
  3. Say why the pool needs mpsc plus Arc<Mutex<Receiver>>, not a cloned Receiver.
  4. Say what Drop does: drop the sender, then join each worker.

The lab Mac /tmp/duha-2026-10-05-web-server (not in this bundle) parses a request line and sketches the pool channel types. cargo test --offline exited 0. Transcript: Duha close note.

Done-criteria: Can walk the book's single-thread then thread-pool server.

This finishes TRPL Topics #18–#24. Post-TRPL corpus arcs open on the next unmarked weekday Duha.

Related