26.3Building the thread pool

Last updated June 13, 2026

This is the centerpiece. We have the interface from lesson 26.2, ThreadPool::new(4) and pool.execute(closure), and now we build it. This single type pulls together more of the course than anything else you'll write: threads and channels and Arc<Mutex> from chapter 22, the closure traits from chapter 19, Box from chapter 21, all in one small module. We'll build it compiler-driven: write the type, let the errors guide each next move.

The Job type: where four chapters meet

Start with the hardest question: what is a unit of work? execute receives a closure and must store it, send it to another thread, and have a worker run it exactly once. Translate each of those requirements into a trait bound, and you get one of the most instructive type signatures in the course:

type Job = Box<dyn FnOnce() + Send + 'static>;

Read it piece by piece, because every piece is a chapter:

That one line is a summary of the second half of the course. When you can read Box<dyn FnOnce() + Send + 'static> and explain why each part is there, you understand Rust's type system. We needed all of it just to say "a closure I can store, ship to another thread, and run once."

The channel and the workers

Now the delivery mechanism. main produces jobs; workers consume them. That's a producer/consumer relationship, which is exactly what channels are for (lesson 22.2). The pool holds the sending end; the workers share the receiving end. But a channel has one receiver, and we have several workers all needing to pull from it. So the receiver must be shared among threads and accessed one-at-a-time, which is precisely Arc<Mutex<T>> (lesson 22.3): Arc to share the receiver across workers, Mutex so only one worker takes a job at a time.

use std::sync::{mpsc, Arc, Mutex};
use std::thread;

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();
        let receiver = Arc::new(Mutex::new(receiver));   // share one receiver among workers

        let mut workers = Vec::with_capacity(size);
        for id in 0..size {
            workers.push(Worker::new(id, 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();
    }
}

Trace it. new asserts a positive size, creates one channel, and wraps the receiver in Arc<Mutex<>> so it can be shared. It then builds size workers, handing each a clone of the shared receiver (Arc::clone, the cheap handle from chapter 21). The pool keeps the sender. execute is short and complete: it boxes the incoming closure into a Job and sends it down the channel. Whichever worker grabs it next will run it. Notice execute's bounds, F: FnOnce() + Send + 'static, are the same constraints that the Job type spelled out; the generic parameter on execute and the dyn in Job are two views of the same requirement.

The worker loop

A Worker owns a thread that loops forever: lock the receiver, wait for a job, run it, repeat.

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || loop {
            let job = receiver
                .lock()              // acquire the mutex
                .unwrap()
                .recv();             // wait for a job from the channel

            match job {
                Ok(job) => {
                    println!("Worker {id} got a job; executing.");
                    job();           // run the closure
                }
                Err(_) => {
                    println!("Worker {id} disconnected; shutting down.");
                    break;
                }
            }
        });

        Worker { id, thread }
    }
}

Each worker spawns a thread (with move, so it owns its id and its receiver handle, lesson 22.1). Inside, it loops: receiver.lock().unwrap().recv() locks the shared mutex, then blocks on recv() waiting for a job. Crucially, the lock is held only momentarily, the MutexGuard from lock() is a temporary that drops right after recv() returns, before job() runs, so one worker running a long job doesn't keep the others from grabbing the next one. When recv returns Ok(job), the worker runs the closure; when it returns Err (the channel closed, all senders gone), the worker breaks out of its loop and ends, which is how the next lesson shuts the pool down cleanly.

Wiring it into the server

Drop the pool into main from lesson 26.1, and the server is now multithreaded:

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);
        });
    }
}

That's the interface we designed, now backed by a real pool. Re-run the experiment from lesson 26.1: request /sleep (the 5-second route) in one tab and / in another. This time the fast page loads immediately, because one of the four workers handles the slow request while the others stay free. The server now serves up to four connections concurrently, and never more than four threads exist no matter how many requests flood in. Bounded concurrency, achieved.

The whole course in one module

Step back and count what this small pool used: a channel to pass work (chapter 22), Arc<Mutex> to share the receiver safely across workers (chapters 21 and 22), thread::spawn with move closures (chapters 19 and 22), Box<dyn FnOnce() + Send + 'static> combining trait objects, closures, the heap, Send, and lifetimes (chapters 16, 19, 21, 22, 17), and structs, generics, and error handling holding it together (chapters 10, 15, 12). No chapter was wasted. A thread pool is a genuinely advanced concurrent data structure, and you just built one from parts you understand individually. That's what the course was for.

Quiz time

Question #1

Explain why a job is typed Box<dyn FnOnce() + Send + 'static>, one reason per piece.

Show solution

FnOnce() because a worker runs each job exactly once (chapter 19). dyn because each closure has a unique anonymous type that can't be named, so it's a trait object (chapter 16). Box because a trait object is unsized and must live behind a pointer on the heap (chapter 21). Send because the job is sent from main to a worker thread and must be safe to cross threads (chapter 22). 'static because the job may live as long as the worker, so it can't borrow anything short-lived (chapter 17).

Question #2

Why is the channel's receiver wrapped in Arc<Mutex<...>>?

Show solution

A channel has a single receiver, but multiple workers all need to pull jobs from it. Arc lets the one receiver be shared (owned) across all the worker threads (each gets a cheap clone of the handle), and Mutex ensures only one worker takes a job at a time, so two workers can't grab the same job or corrupt the receiver. It's the chapter-22 Arc<Mutex<T>> pattern: shared ownership plus safe, exclusive access.

Question #3

Why does the worker hold the mutex lock only briefly, and why does that matter?

Show solution

The worker locks the receiver just long enough to call recv() and get a job; the MutexGuard is a temporary that drops right after, before the job runs, so the lock is released while job() executes. This matters because if the worker held the lock for the whole duration of running the job, no other worker could acquire it to take the next job, serializing everything and defeating the pool. Releasing the lock before running the job lets the other workers grab jobs concurrently.

The server handles many connections at once. But it never stops cleanly, kill it and any in-progress requests die mid-flight, and the workers are never properly joined. The final lesson (26.4) adds graceful shutdown with a Drop implementation (chapter 21), and closes the course.