The kitchen rail problem
You are building a service that resizes images. Requests arrive fast. If you spawn a new thread for every image, the operating system chokes on thread creation overhead and memory limits. If you handle images one by one on the main thread, users stare at a loading spinner while the queue backs up. You need a middle ground: a fixed set of worker threads waiting for tasks, grabbing one when available, and going back to sleep when the work is done.
That is a worker pool. It decouples the rate of incoming work from the rate of processing. The pool absorbs bursts and keeps the system stable.
Think of a busy restaurant kitchen. Waiters drop orders on a ticket rail. Chefs stand by the rail. When a chef finishes a dish, they grab the next ticket. If the rail is empty, they wait. The rail is the channel. The chefs are the threads. The pool is the kitchen management system that keeps the chefs fed without hiring a new chef for every order.
The core mechanism
A worker pool needs three things. A collection of worker threads. A way to send jobs to those threads. A way to share the job queue among workers safely.
Rust provides std::sync::mpsc for the channel. It gives you a sender and a receiver. The sender puts values in. The receiver takes values out. The tricky part is sharing the receiver. Multiple workers need to read from the same receiver, but mpsc::Receiver is not thread-safe for concurrent access. You cannot just pass the receiver to multiple threads.
The solution combines two wrappers. Arc provides shared ownership across threads. Mutex provides exclusive access to the receiver. Every worker gets a clone of the Arc. When a worker wants a job, it locks the mutex, calls recv, and unlocks. Only one worker can grab a job at a time.
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
/// A boxed closure that runs once, can be sent across threads,
/// and owns all its data.
type Job = Box<dyn FnOnce() + Send + 'static>;
/// Manages a fixed number of worker threads and distributes jobs.
pub struct ThreadPool {
workers: Vec<Worker>,
sender: mpsc::Sender<Job>,
}
/// A single worker thread waiting for jobs.
struct Worker {
id: usize,
thread: Option<thread::JoinHandle<()>>,
}
impl ThreadPool {
/// Creates a pool with the specified number of threads.
/// Panics if size is zero.
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
// Create the channel. The sender stays in the pool.
// The receiver gets wrapped for sharing.
let (sender, receiver) = mpsc::channel();
// Arc allows multiple workers to own the receiver.
// Mutex ensures only one worker calls recv at a time.
let receiver = Arc::new(Mutex::new(receiver));
let mut workers = Vec::with_capacity(size);
for id in 0..size {
// Arc::clone is the convention. It signals a reference count bump,
// not a deep copy of the data.
workers.push(Worker::new(id, Arc::clone(&receiver)));
}
ThreadPool { workers, sender }
}
/// Sends a closure to a worker for execution.
/// Blocks if the channel is full.
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
// Box the closure to match the Job type alias.
let job = Box::new(f);
// unwrap is safe here. Err means all workers dropped,
// which indicates a logic error in the pool lifecycle.
self.sender.send(job).unwrap();
}
}
Convention aside: always use Arc::clone(&value) instead of value.clone() for Arc. Both compile and do the same thing. The explicit form tells readers you are incrementing the reference count, not copying the inner data. It prevents confusion with types where clone performs a deep copy.
Anatomy of a job
The Job type alias defines what the pool can run. It is a trait object: Box<dyn FnOnce() + Send + 'static>. Each trait bound has a specific purpose.
FnOnce means the closure runs exactly once. The pool takes ownership of the closure and calls it. After execution, the closure is gone. This matches the semantics of a task: it happens, then it's done.
Send is the safety guarantee. A Send type can be transferred across thread boundaries safely. It means the type does not contain non-atomic reference counts, raw pointers, or other state that could cause data races when moved to another thread. If you try to send a closure capturing a non-Send type, the compiler rejects it with E0277 (trait bound not satisfied).
'static means the closure owns all its data. It cannot borrow references with limited lifetimes. If a closure captured a reference to a local variable, that variable might be dropped while the worker is still running. The 'static bound forces the closure to capture by value or own the data, preventing dangling pointers.
These bounds are not optional. They are the contract that keeps the pool safe. The compiler enforces them at the call site. If your closure violates them, you fix the closure, not the pool.
The worker loop
Each worker spawns a thread that runs an infinite loop. The loop locks the shared receiver, waits for a job, executes it, and repeats.
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(move || {
loop {
// Lock the mutex to access the receiver.
// recv blocks until a job arrives or the sender drops.
let message = receiver.lock().unwrap().recv();
match message {
Ok(job) => {
// Execute the job. The closure runs once and is consumed.
job();
}
Err(_) => {
// The sender dropped. No more jobs will arrive.
// Break the loop to shut down this worker.
break;
}
}
}
});
Worker { id, thread: Some(thread) }
}
}
The recv call is the heartbeat of the worker. It blocks the thread until a job is available. This is efficient: the thread sleeps and consumes no CPU while waiting. When a job arrives, the thread wakes up, grabs the job, and runs it.
The Err case handles shutdown. The mpsc channel returns Err when the sender is dropped. The ThreadPool holds the only sender. When the pool goes out of scope, the sender drops. All workers see Err on their next recv call and break out of the loop. The threads terminate cleanly.
The pool dies with the sender. Drop the pool, drop the threads.
Real-world usage
Here is how you use the pool. You create it with a size, send jobs, and let it run.
fn main() {
// Create a pool with 4 workers.
let pool = ThreadPool::new(4);
// Send 10 jobs. They will be distributed among the 4 workers.
for i in 0..10 {
pool.execute(move || {
println!("Worker processing job {}", i);
// Simulate work.
thread::sleep(std::time::Duration::from_millis(100));
});
}
// pool goes out of scope here.
// The sender drops. Workers receive Err and exit.
}
The move keyword in the closure is essential. It forces the closure to capture i by value. Without move, the closure would try to borrow i, which violates the 'static bound because i is a local variable. The compiler catches this.
Keep the lock scope tiny. The mutex is the bottleneck.
Pitfalls and compiler errors
Worker pools introduce concurrency hazards. Rust's type system catches many at compile time, but some require careful design.
Blocking sends. The mpsc::Sender::send method blocks if the channel buffer is full. The default mpsc channel has no buffer. If all workers are busy and you call execute, the calling thread blocks until a worker finishes and calls recv. This can cause deadlocks if the main thread waits for workers while holding the sender. Use try_send if you need non-blocking behavior, or ensure your architecture allows the sender to block safely.
Mutex contention. All workers contend for the same mutex to grab jobs. If jobs are tiny and frequent, the lock overhead dominates. The workers spend more time fighting for the mutex than doing work. For fine-grained parallelism, a worker pool is the wrong tool.
Non-Send captures. If you capture a type that is not Send, the compiler rejects the closure with E0277. Common culprits include Rc<T>, raw pointers, and types containing UnsafeCell without proper synchronization. Replace Rc with Arc. Wrap raw pointers in UnsafeCell with a Mutex or Atomic.
Panic propagation. If a job panics, the worker thread panics. The JoinHandle for that thread will return Err if you join it. In this implementation, the worker thread terminates on panic. The pool loses a worker. The remaining workers continue. If you need resilience, you must catch panics inside the worker loop using std::panic::catch_unwind.
Treat the Send bound as a firewall. It keeps data races out.
Decision matrix
Worker pools are a specific tool. Choose the right concurrency primitive for your workload.
Use a manual ThreadPool when you need fine-grained control over channel logic, backpressure, or worker lifecycle. Use it when you are building a library that exposes a pool interface and want to avoid external dependencies.
Use rayon when you are parallelizing data-parallel work like mapping over a slice, reducing a collection, or iterating with a fixed pattern. Rayon handles work-stealing and load balancing automatically.
Use tokio or async-std when your work involves I/O and you need an async runtime with millions of lightweight tasks. Async runtimes multiplex tasks onto fewer threads and yield control during waits.
Use std::thread::spawn for one-off background tasks where pooling overhead is not justified. Use it when the task runs for the lifetime of the program or has unique resource requirements.
Reach for plain references when lifetimes are simple; the unsafe alternative is rarely worth it.