I wanted hands-on experience with a low-level language. Managing memory through language rules, not a runtime. I chose Rust and followed the Rust Book Chapter 20 — a multithreaded HTTP server. About 200 lines, using only the standard library with no external crates.
Memory Management
Rust has no garbage collector. Instead, the ownership system determines when memory is freed at compile time.
Every value has exactly one owner. When the owner goes out of scope, the value is automatically dropped. Assigning a value to another variable moves ownership, and the original variable becomes unusable. The compiler enforces this.
let s1 = String::from("hello");
let s2 = s1; // ownership moves
// s1 is no longer usable — compile error
Values can be borrowed without transferring ownership. Through references (&). Multiple immutable references can coexist, but only one mutable reference (&mut) is allowed at a time. This rule prevents data races at compile time.
In GC-based languages, the runtime handles memory reclamation. Rust delegates that decision to the compiler. Memory safety with zero runtime cost.
Thread Pool
The core of the server is its thread pool. When a TCP connection arrives, work is distributed to worker threads.
Work distribution uses channels. A single sender dispatches jobs, and multiple workers share the receiver. The problem was that Rust’s Receiver does not implement Clone. Sharing one receiver across multiple threads required a different approach.
Arc<Mutex<Receiver<T>>> was the answer. Arc enables multiple threads to own the same value through reference counting. Mutex ensures only one thread accesses the receiver at a time. This was where ownership rules extended naturally from single-threaded to concurrent contexts.
let (sender, receiver) = mpsc::channel();
let receiver = Arc::new(Mutex::new(receiver));
for id in 0..size {
let receiver = Arc::clone(&receiver);
// each worker shares the receiver's reference count
}
Arc::clone() does not copy the value. It only increments the reference count. The type system makes this distinction explicit.
Graceful Shutdown
The Drop trait is called automatically when a value goes out of scope. I used it to clean up workers when the pool is destroyed.
Ordering mattered. First, send a Terminate message to every worker. Then join each thread. Reversing this order risks deadlock — blocking on the first worker’s join while the remaining workers never receive the shutdown signal.
// 1. send termination signals first
for _ in &self.workers {
self.sender.send(Message::Terminate)?;
}
// 2. then join
for worker in &mut self.workers {
if let Some(thread) = worker.thread.take() {
thread.join()?;
}
}
Declaring worker.thread as Option<JoinHandle<()>> was an idiomatic Rust pattern. take() extracts the handle, leaving None in its place. This prevents double-joining the same thread at the type level.
Trait Bounds
The thread pool’s generic type has three constraints.
Pool<T: FnOnce() + Send + 'static>
FnOnce means the closure is called exactly once. A job runs once on one worker and that is it. Send guarantees the closure can be safely transferred to another thread. 'static constrains the closure’s referenced values to live for the entire program. Since a thread’s lifetime is unpredictable, this prevents borrowed references from being freed prematurely.
Remove any one of these three and the code will not compile. In Go, passing a closure to a goroutine has no such constraints. Race conditions are detected at runtime with the -race flag instead. Rust moves that verification to the compiler.
Retrospective
This project started from wanting to experience a low-level language. What I actually experienced was “safety enforced by the compiler” more than “low-level.”
Without composing Arc<Mutex<T>>, multiple threads cannot share a receiver. Without specifying FnOnce + Send + 'static, a closure cannot be sent to a thread. Without declaring Option<JoinHandle>, take() is unavailable. The compiler explains through error messages why each combination is necessary, and resolving them guarantees concurrency safety.
I started because I wanted to work with a low-level language. What I learned was the experience of a type system catching concurrency bugs before runtime.