Skip to content

← The Expert Edge: Idiom, Review and Capstones step 6 of 14

Hard End-to-End

Capstone: build a thread pool

Build a thread pool using the standard library. Queue jobs for its workers, let different workers run jobs concurrently, and finish all queued work before the pool is dropped.

pub fn pool_run(jobs: Vec<u64>, workers: usize) -> Vec<u64>

Keep the supplied work and pool_run functions. Implement Worker, ThreadPool::new, ThreadPool::execute and Drop for ThreadPool. You may change their fields to support ownership and shutdown.

Required behavior

  • Create workers threads, or one thread when workers is zero.
  • Queue each submitted closure for exactly one execution. Do not hold the receiver lock while executing a job.
  • On drop, stop accepting jobs, finish the queued jobs and join every worker. An empty pool must also shut down without hanging.
  • Return one answer per input, preserving order and duplicates. Empty input returns an empty vector.

work counts Collatz steps until the value is at most one. Each step divides an even value by two, or replaces an odd value with 3 * n + 1. Both zero and one take zero steps. For example, [0, 1, 3, 6] produces [0, 0, 7, 8], regardless of the worker count.

This exercise uses terminating inputs whose intermediate Collatz values fit in u64, and worker counts small enough for the runtime to create the threads. It does not ask you to prove Collatz termination, handle arbitrary overflowing sequences or recover from thread-creation failure. Jobs are assumed to finish without panicking. A production pool would need an explicit policy for panics, cancellation and resource limits.

Sending different closures through one channel

The provided job type is Box<dyn FnOnce() + Send + 'static>:

  • FnOnce permits a closure to consume captured values when called.
  • Box<dyn ...> lets the channel hold closures with different concrete types.
  • Send permits transferring a job to a worker thread.
  • 'static rules out captures that borrow shorter-lived data. Owned values and static references are allowed; the job itself need not live forever.

A boxed FnOnce is directly callable. This complete example moves a string into a job, then consumes it when the job runs:

fn main() {
    let text = String::from("queued work");
    let job: Box<dyn FnOnce() + Send + 'static> = Box::new(move || {
        assert_eq!(text, "queued work");
        drop(text);
    });
    job();
}

Store the receiving end of the job channel in Arc<Mutex<mpsc::Receiver<Job>>>. The receiver is not cloneable or Sync. The mutex gives one worker at a time access to it, and each worker owns a clone of the Arc.

Release the lock before running a job

Each worker repeats four steps: lock the receiver, receive a job, release the lock, then run the job. Use a separate block for the lock guard so its scope ends before the call to the closure.

The following complete example demonstrates that scope with a channel of integers. It checks that the mutex is available inside the message handler:

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

fn main() {
    let (sender, receiver) = mpsc::channel();
    sender.send(7).expect("receiver is alive");
    drop(sender);
    let receiver = Mutex::new(receiver);

    loop {
        let message = {
            let guard = receiver.lock().expect("receiver mutex poisoned");
            guard.recv()
        };
        match message {
            Ok(value) => {
                assert_eq!(value, 7);
                assert!(receiver.try_lock().is_ok());
            }
            Err(_) => break,
        }
    }
}

Be careful with while let Ok(job) = rx.lock().expect("lock").recv(). Its temporary guard remains alive through the successful iteration's body. Calling job() there holds the receiver lock during the job, preventing other workers from receiving work. The guard is released at the end of that iteration, not held once for the entire loop.

Close the channel before joining

Dropping the final sender disconnects the channel. Workers can still receive messages already queued; recv() returns an error once the queue is empty. Use that error to exit the worker loop.

The pool must release its sender before joining workers. Otherwise an idle worker can wait in recv() while the pool waits in join(), with neither able to continue. An extra sender clone kept elsewhere would also prevent disconnection. A timeout may indicate this deadlock, but it can have other causes too.

Taking ownership inside Drop

Drop::drop receives &mut self, while JoinHandle::join consumes its handle. The starter tries to move a handle out through a mutable reference, producing E0507.

Store each handle in an Option. Calling .take() moves the handle out and leaves None behind, so you can join the returned handle. Store the sender in an Option for the same reason: take and drop it first, then take and join each worker handle.

Output order and concurrency checks

Workers may finish jobs in any order. The supplied pool_run stores each result in a slot identified by its input index. It reads those slots after dropping the pool, when all workers should have finished.

The output tests check answers and ordering, including the same batch with one and eight workers. They do not by themselves prove that jobs overlap: an accidentally serialized pool can produce the same answers. As an extra check, submit two jobs to a two-worker pool and have each wait at a shared barrier. They must both reach the barrier for either to proceed. Run this check with a timeout so a broken pool cannot hang your test indefinitely.

Completion-order APIs can also be tested, but input order is the contract of this exercise. No particular completion schedule is required.

Submissions must compile, pass the tests and pass clippy -D warnings.