-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Ownership and Borrowing
-
Section 4: Structuring Data
-
Section 5: Collections and Error Handling
-
Section 6: Traits and Generics
-
Section 7: Concurrency
-
Section 8: Building for the Web
-
Section 9: Memory and Performance
-
Section 10: More Standard Library and Ecosystem
-
Section 11: Advanced Rust
-
Section 12: Rust for Systems and WebAssembly
-
Section 13: Tooling and Best Practices
-
Section 14: Data Structures and Algorithms in Rust
-
Section 15: Practical Projects
-
Section 16: Interview Practice
-
Section 17: std::collections In Depth
-
Section 18: std::io and std::fs In Depth
-
Section 19: std::net
-
Section 20: std::option and std::result In Depth
-
Section 21: std::iter In Depth
-
Section 22: std::sync In Depth
-
Section 23: std::string and std::str
-
Section 24: Cargo and Crates.io Ecosystem
-
Section 25: Popular Crates Ecosystem
-
Section 26: Rust Memory Model Deep Dive
-
Section 27: More Practice Exercises
-
Section 28: More Interview Practice
-
Section 29: Async Rust Deep Dive
-
Section 30: Tokio Ecosystem In Depth
-
Section 31: Error Handling Ecosystem Deep Dive
-
Section 32: Serde In Depth
-
Section 33: Web Development with Rust Deep Dive
-
Section 34: Database Access Ecosystem
-
Section 35: Rust for Embedded Systems Deep Dive
-
Section 36: Rust Macros In Depth
-
Section 37: Advanced Trait System
-
Section 38: Unsafe Rust In Depth
-
Section 39: Rust CLI Development
-
Section 40: Testing Ecosystem Deep Dive
-
Section 41: WebAssembly Deep Dive
-
Section 42: Rust Design Patterns
-
Section 43: More Data Structures in Rust
-
Section 44: Final Practice Projects
-
Section 45: Rust Performance Optimization
-
Section 46: Rust Ecosystem Tooling
-
Section 47: More Interview and Review
248: Whiteboard Practice: Implementing a Thread Pool from Scratch
When you're at a whiteboard in a technical interview or sketching out a system architecture, the "Thread Pool" is a classic. It tests your understanding of concurrency, ownership, and how to actually manage resources without letting your program spin out of control. I've seen plenty of developers struggle here because they try to over-engineer the solution before getting the basic communication channel working.
We're going to build a minimal thread pool that can execute arbitrary closures. The goal is to avoid the overhead of spawning a new thread for every single task, which—as you know—would kill your performance if you had thousands of small jobs.
Defining the Job and the Worker
First, I need a way to represent a "job." Since we want to pass functions into our pool, a boxed closure is the way to go. I'll define a type alias to keep the signatures clean.
type Job = Box<dyn FnOnce() + Send + 'static>;
Now, I need a Worker. The worker isn't the pool itself; it's just a wrapper around a thread that loops forever, waiting for a job to arrive on a channel. I'll give it an ID so we can track which worker is doing what in the logs.
struct Worker {
id: usize,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Worker {
fn new(id: usize, receiver: std::sync::Arc<std::sync::Mutex<std::sync::mpsc::Receiver<Job>>>) -> Worker {
let thread = std::thread::spawn(move || loop {
let message = receiver.lock().unwrap().recv();
match message {
Ok(job) => {
println!("Worker {} got a job; executing.", id);
job();
}
Err(_) => {
println!("Worker {} disconnecting; shutting down.", id);
break;
}
}
});
Worker { id, thread: Some(thread) }
}
}
The "Sync" Wall and My Usual Mistake
Here is where I almost tripped up. When I first sketched this out, I tried to pass the Receiver directly to each worker. I thought, "It's just a receiver, why can't they all have one?" But Rust's mpsc stands for Multi-Producer, Single-Consumer. The receiver is explicitly not Sync.
If I had just tried move || receiver.recv() inside the loop without the Mutex, the compiler would have yelled at me that the receiver cannot be shared between threads. To fix this, I wrapped the receiver in an Arc<Mutex<...>>. The Arc lets multiple workers own the receiver, and the Mutex ensures that only one worker at a time can actually pull a job off the queue. It's a small detail, but it's the heart of how this pattern works in Rust.
Implementing the ThreadPool Orchestrator
Now we need the ThreadPool struct to manage the workers and the sending end of the channel. This is the public API that the rest of your application will interact with.
pub struct ThreadPool {
workers: Vec<Worker>,
sender: std::sync::mpsc::Sender<Job>,
}
impl ThreadPool {
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
let (sender, receiver) = std::sync::mpsc::channel();
let receiver = std::sync::Arc::new(std::sync::Mutex::new(receiver));
let mut workers = Vec::with_capacity(size);
for id in 0..size {
workers.push(Worker::new(id, std::sync::Arc::clone(&receiver)));
}
ThreadPool { workers, sender }
}
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static
{
let job = Box::new(f);
self.sender.send(job).unwrap();
}
}
I used a generic F in the execute method. This allows the user to pass in any closure that meets our Job requirements. By boxing it inside the method, we erase the specific type of the closure, allowing the channel to handle different closures as long as they all fit the FnOnce() + Send + 'static trait bounds.
Putting it to the Test
To make sure this actually works, I'll run a few tasks. I'll simulate some "heavy" work using std::thread::sleep to prove that the tasks are actually running in parallel across the pool.
fn main() {
let pool = ThreadPool::new(4);
for i in 0..8 {
pool.execute(move || {
println!("Processing task {}...", i);
std::thread::sleep(std::time::Duration::from_millis(500));
});
}
// Give them a moment to finish before the main thread exits
std::thread::sleep(std::time::Duration::from_secs(2));
}
When you run this, you'll notice that the first four tasks start immediately, and the next four only start as the workers become available. That's the "pool" in action.
📋 Practical Task
Exercise: Implementing Graceful Shutdown for the ThreadPool
Currently, our ThreadPool just drops the workers when it goes out of scope, which can lead to tasks being cut off abruptly. Your task is to implement the Drop trait for ThreadPool to ensure all workers finish their current job and shut down cleanly.
- Modify the
ThreadPoolimplementation to include aimpl Drop for ThreadPoolblock. - Inside
drop, you must explicitly drop thesenderfirst. This tells the workers'receiver.recv()calls to return anErr, triggering theirbreakloop. - Iterate through the
workersvector, take theJoinHandleout of theOption(using.take()), and call.join().unwrap()on each one. - Verify that the program waits for all active workers to finish their final task before the process exits.
There are no comments for now.