Skip to Content
Course content

248: Whiteboard Practice: Implementing a Thread Pool from Scratch

Click on the "Edit" button in the top corner of the screen to edit your slide content.

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 ThreadPool implementation to include a impl Drop for ThreadPool block.
  • Inside drop, you must explicitly drop the sender first. This tells the workers' receiver.recv() calls to return an Err, triggering their break loop.
  • Iterate through the workers vector, take the JoinHandle out of the Option (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.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.