Final project: building a multithreaded web server
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:
- The Single-Threaded Server:
TcpListeneraccepts a stream; read one request line; write an HTTP/1.1 response. - The Thread Pool: a fixed set of workers pull jobs from
mpscthroughArc<Mutex<Receiver>>. - Graceful Shutdown:
DropforThreadPooldrops 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
- Name the three stages: single-threaded HTTP, thread pool, graceful shutdown.
- Point at
TcpListener::bind→ request line →write_allof an HTTP/1.1 response. - Say why the pool needs
mpscplusArc<Mutex<Receiver>>, not a clonedReceiver. - Say what
Dropdoes: drop the sender, thenjoineach 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.