Final Project: Building a Multithreaded Web Server
The capstone: TCP, HTTP, a thread pool with channels, and graceful shutdown — every prior lesson composed.
Part of Rust Interview Mastery · Based on The Rust Programming Language
TCP & HTTP
Walk through the single-threaded server — what does the book build first?
let listener = TcpListener::bind("127.0.0.1:7878")?;
for stream in listener.incoming() {
let stream = stream?;
handle_connection(stream);
}
TcpListener binds the port; incoming() yields TcpStream results (one per connection). handle_connection wraps the stream in BufReader, reads the request line + headers, routes on the method/path, and writes an HTTP response back through the same stream. It's the socket-level view of every web framework.
Describe an HTTP request and response at the wire level.
Request: GET /path HTTP/1.1 request line, then headers (Host:, User-Agent:...), a blank CRLF line, then an optional body. Response: status line HTTP/1.1 200 OK, headers including Content-Length, blank line, body.
HTTP/1.1 200 OK Content-Length: 13 Hello, world!
Content-Length tells the client where the body ends — the server code computes it from the response string. Parsing is intentionally naive in the book (just lines()), which is why real servers use battle-tested parsers — the exercise is about I/O structure, not a production parser.
Why does the single-threaded server fail the /sleep test — and what's the real lesson?
The handler for /sleep calls thread::sleep(10s). Because the accept loop handles connections sequentially, every other request waits behind it — open two tabs and the second one hangs 10+ seconds.
The lesson isn't "sleep is bad" — it's that serialization of independent work is the bottleneck. Any per-connection work (DB query, upstream call) blocks the next connection. That's the motivation for the thread pool, and later for async (ch 17 tackles it without OS threads).
The ThreadPool
Design a ThreadPool — how do jobs reach workers?
struct ThreadPool { workers: Vec<Worker>, sender: mpsc::Sender<Job> }
type Job = Box<dyn FnOnce() + Send + 'static>;
impl ThreadPool {
fn execute<F: FnOnce() + Send + 'static>(&self, f: F) {
self.sender.send(Box::new(f)).unwrap();
}
}
struct Worker { id: usize, thread: JoinHandle<()> }
// worker loop: while let Ok(job) = receiver.lock().unwrap().recv() { job() }
The pool holds Sender<Job>; N workers each loop { recv() } on a shared Receiver. execute boxes the closure — dyn FnOnce + Send because jobs must be transferable to a worker thread and called exactly once.
Why does the pool need Arc<Mutex<mpsc::Receiver<Job>>>? Explain each piece.
All three bounds do work:
- mpsc Receiver isn't Clone — one receiver exists; workers must share it, so Arc shares ownership across worker threads.
- Receiver::recv takes &mut self — concurrent &mut borrows are illegal, so Mutex serializes access: one worker locks, recvs (blocking until a job arrives), gets the job, unlocks.
- Send bound on Job — the closure crosses thread boundaries (caller → worker), so it must be Send; Box<dyn FnOnce> because it's called once and must be 'static (no borrows from the submitter's stack).
It's ch 15–16 in one line: Arc for shared ownership, Mutex for exclusive mutation, Send for thread transfer.
Why is Box<dyn FnOnce() + Send> needed instead of a generic?
Generics would make Job a concrete type — but different requests submit different closure types (each closure is a unique anonymous type). A Vec<F> couldn't hold them all. Box<dyn FnOnce> type-erases: uniform size, vtable call, all closure shapes welcome.
FnOnce (not Fn) because a job runs exactly once — and Box<dyn FnOnce> historically needed the FnBox workaround; modern Rust calls Box<dyn FnOnce()> directly. Send because the job crosses threads; 'static because the pool can't borrow from a handler's stack frame that might return first.
Explain graceful shutdown — how do workers stop?
The elegant trick: the worker loop is while let Ok(job) = receiver.recv(). recv returns Err when all Senders are dropped. So shutdown = drop the pool's sender (wrapped in Option so take() can move it out):
impl Drop for ThreadPool {
fn drop(&mut self) {
drop(self.sender.take());
for w in &mut self.workers {
if let Some(t) = w.thread.take() { t.join().unwrap(); }
}
}
}
Each worker's recv unblocks with Err, breaks the loop, its JoinHandle is joined. Option::take() is the key move — it moves the field out leaving None, so Drop can own what it needs (the join needs ownership of the handle).
What limits does a fixed thread pool still have, and where does that lead next?
A pool of N threads handles N concurrent requests — under heavy load, requests still queue, and each idle-blocked thread costs ~MBs of stack. If handlers block (DB calls), you burn threads waiting — that's the resource wall thread pools hit.
That's the setup for async (ch 17): one thread drives thousands of in-flight connections because futures yield instead of blocking. The book's capstone lands exactly at the question async answers: how do you get concurrency without paying for threads?