Web Server Project ​
Updated Mar 2026Build a multithreaded web server from scratch using only the standard library. This project ties together TCP networking, HTTP parsing, concurrency with threads, channels, mutexes, and graceful shutdown. Based on Chapter 21 of The Rust Programming Language.
Architecture Overview ​
Phase 1: Single-Threaded Server ​
TCP Listener ​
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();
handle_connection(stream);
}
}listener.incoming() returns an iterator of TcpStream connections. Each stream represents an open connection between client and server.
Reading HTTP Requests ​
use std::io::{BufReader, prelude::*};
use std::net::TcpStream;
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
println!("Request: {request_line}");
}HTTP request format:
Method Request-URI HTTP-Version CRLF
headers CRLF
CRLF
message-bodySending Responses ​
use std::fs;
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 = 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();
}HTTP response format:
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
CRLF
message-bodyThe Single-Thread Problem ​
If one request takes 5 seconds (like a /sleep endpoint), ALL other requests are blocked:
use std::thread;
use std::time::Duration;
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) = match &request_line[..] {
"GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(5)); // blocks ALL requests
("HTTP/1.1 200 OK", "hello.html")
}
_ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
};
// ... send response
}Phase 2: Thread Pool ​
Why Not Thread-Per-Request ​
Spawning a new thread for every request is a denial-of-service vulnerability. An attacker can exhaust system resources by opening many connections. A thread pool limits concurrency to a fixed number of workers.
Thread Pool Design ​
The Job Type ​
type Job = Box<dyn FnOnce() + Send + 'static>;FnOnce(): a closure that runs once and takes no argumentsSend: safe to transfer between threads'static: no borrowed data that might dangle
ThreadPool Implementation ​
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
pub struct ThreadPool {
workers: Vec<Worker>,
sender: Option<mpsc::Sender<Job>>,
}
type Job = Box<dyn FnOnce() + Send + 'static>;
impl ThreadPool {
/// Create a new ThreadPool.
///
/// The size is the number of threads in the pool.
///
/// # Panics
///
/// The `new` function will panic if the size is zero.
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: Some(sender),
}
}
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let job = Box::new(f);
self.sender.as_ref().unwrap().send(job).unwrap();
}
}Worker Implementation ​
struct Worker {
id: usize,
thread: Option<thread::JoinHandle<()>>,
}
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(move || loop {
let message = receiver.lock().unwrap().recv();
match message {
Ok(job) => {
println!("Worker {id} got a job; executing.");
job();
}
Err(_) => {
println!("Worker {id} disconnected; shutting down.");
break;
}
}
});
Worker {
id,
thread: Some(thread),
}
}
}Key Design Decisions ​
Arc<Mutex<Receiver>> -- Shared Ownership of the Channel
mpsc::channel()creates a single-producer, multiple-consumer channel- But
Receiveris notClone-- only one receiver exists Arcallows multiple workers to share ownershipMutexensures only one worker reads from the channel at a time
Why let message = ... Instead of while let
// BAD: holds the MutexGuard through job execution
while let Ok(job) = receiver.lock().unwrap().recv() {
job(); // lock is STILL held here -- blocks other workers!
}
// GOOD: lock is released before job executes
loop {
let message = receiver.lock().unwrap().recv();
match message {
Ok(job) => {
// MutexGuard dropped here (end of let binding)
job(); // lock is FREE -- other workers can recv()
}
Err(_) => break,
}
}The while let form keeps the MutexGuard alive through the entire job() execution because it's a temporary value in the while let expression. The let binding form drops the guard at the semicolon.
Using the Thread Pool ​
use hello::ThreadPool;
use std::net::TcpListener;
fn main() {
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);
});
}
}Now /sleep requests only block one worker thread. Other workers handle concurrent requests.
Phase 3: Graceful Shutdown ​
The Drop Implementation ​
impl Drop for ThreadPool {
fn drop(&mut self) {
// Step 1: Close the channel by dropping the sender
drop(self.sender.take());
// Step 2: Wait for all workers to finish
for worker in self.workers.drain(..) {
println!("Shutting down worker {}", worker.id);
if let Some(thread) = worker.thread.take() {
thread.join().unwrap();
}
}
}
}Shutdown Sequence ​
Step by step:
drop(self.sender.take())-- moves the sender out of theOptionand drops it, closing the channel- Workers'
recv()returnsErr-- when the channel is closed, all pendingrecv()calls returnErr - Workers break out of their loops -- the
Errarm in thematchcallsbreak thread.join()waits for each worker -- ensures all in-progress jobs complete
Why Option is Used ​
Both sender and thread are wrapped in Option so take() can move the value out during shutdown:
// sender: Option<mpsc::Sender<Job>>
// Without Option, we can't move sender out of &mut self
// thread: Option<thread::JoinHandle<()>>
// Without Option, we can't move the JoinHandle to call join()Testing Graceful Shutdown ​
Limit the server to a fixed number of requests:
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
let pool = ThreadPool::new(4);
// Only handle 2 requests, then shut down
for stream in listener.incoming().take(2) {
let stream = stream.unwrap();
pool.execute(|| {
handle_connection(stream);
});
}
println!("Shutting down.");
// ThreadPool::drop() is called here automatically
}Complete Project Structure ​
hello/
├── Cargo.toml
├── src/
│ ├── main.rs # TCP listener + thread pool usage
│ └── lib.rs # ThreadPool + Worker implementation
├── hello.html # Success page
└── 404.html # Not found pageConcepts Demonstrated ​
| Concept | Where Used |
|---|---|
| TCP networking | TcpListener, TcpStream |
| HTTP protocol | Request parsing, response formatting |
| Threads | thread::spawn in workers |
| Channels | mpsc::channel for job dispatch |
| Mutexes | Mutex<Receiver> for shared access |
| Arc | Arc<Mutex<Receiver>> for shared ownership |
| Closures | FnOnce() + Send + 'static as Job type |
| Trait objects | Box<dyn FnOnce()> for type erasure |
| Drop trait | Graceful shutdown implementation |
| Option | take() pattern for ownership transfer |
| Pattern matching | HTTP method routing |
| Error handling | unwrap() and match arms |
Possible Extensions ​
Once the basic server works, consider these enhancements:
| Extension | Key Concepts |
|---|---|
| Add more routes | Pattern matching, modular handlers |
| Serve static files | fs::read, MIME types, Path |
| Connection keep-alive | Loop per connection, timeouts |
| Request body parsing | Content-Length, POST handling |
| async/await rewrite | tokio::net::TcpListener, async tasks |
| Configuration file | serde, toml crate |
| Logging | log crate, env_logger |
| TLS support | rustls or native-tls crate |