One Line of Rust Made My Thread Pool 4x Faster
Two nearly identical implementations. A 4x performance difference. The culprit? A MutexGuard that lived longer than I expected.
Originally published on dev.to in February 2022.
I was building a simple HTTP server in Rust when I noticed something ridiculous.
Two nearly identical implementations of my thread pool had a roughly 4x performance difference.
Same threads. Same channel. Same jobs.
The only difference was how I acquired a mutex.
Can you spot the problem?
The two implementations
Snippet 1: Fast
let job = receiver.lock().unwrap().recv().unwrap();
job();
Snippet 2: Slow
let lock = receiver.lock().unwrap();
let job = lock.recv().unwrap();
job();
They look equivalent, right?
That’s what I thought.
But when I benchmarked them using Artillery on a machine with 16 logical processors, the first implementation performed roughly 4x better.
And the reason turned out to be surprisingly simple.
The problem
My thread pool used Rust’s mpsc channel to distribute incoming HTTP requests across multiple worker threads.
Since mpsc supports multiple producers but only one receiver, I wrapped the receiver in Arc<Mutex<_>> so the workers could share it safely.
Each worker would:
- Acquire the mutex.
- Receive a job from the channel.
- Execute the job.
- Repeat.
Except there was one tiny detail I’d overlooked.
When exactly does the mutex get released?
Snippet 1: The mutex is released before the job runs
let job = receiver.lock().unwrap().recv().unwrap();
// MutexGuard dropped here.
job();
The mutex guard is a temporary value.
Rust drops it at the end of the statement, immediately after recv() returns.
That means another worker can acquire the mutex and receive the next job while the current worker executes its own.
Exactly what we want.
Snippet 2: The mutex stays locked during execution
let lock = receiver.lock().unwrap();
let job = lock.recv().unwrap();
job();
// MutexGuard dropped here, at the end of the scope.
Here, the guard is stored in a variable.
And because that variable remains in scope, the mutex stays locked while job() executes.
So even though I had 16 worker threads, only one could execute a job at a time.
The other workers were stuck waiting for the mutex.
I’d accidentally turned my multithreaded server into something that effectively processed jobs sequentially.
All because I introduced a variable.
The benchmarks
I tested both implementations using Artillery and monitored CPU activity to understand what was happening.
Snippet 1: Temporary guard
The workers could execute jobs in parallel because the mutex was released before each job ran.
Snippet 2: Named guard
The mutex remained locked during job execution, effectively serializing the work.
Under my test workload, the first implementation delivered roughly 4x the performance.
Of course, that number depends on the workload, hardware, and how much time each job spends executing.
But the underlying bug doesn’t.
The fix
If you prefer the readability of a named variable, you can explicitly limit its scope:
let job = {
let lock = receiver.lock().unwrap();
lock.recv().unwrap()
}; // MutexGuard dropped here.
job();
Or just call drop() yourself:
let lock = receiver.lock().unwrap();
let job = lock.recv().unwrap();
drop(lock);
job();
Both ensure the mutex is released before the job executes.
One important detail: recv() itself still happens while holding the lock, so other workers cannot receive jobs until it returns. That’s expected with this shared-receiver design.
The important part is not holding the lock while executing the job.
The lesson
Rust’s compiler prevents data races, but it can’t prevent you from accidentally destroying your application’s concurrency.
Both implementations compile.
Both are memory-safe.
One just performs dramatically worse because the mutex lives longer than necessary.
The next time your multithreaded Rust code is mysteriously slow, check how long you’re holding your locks.
Sometimes the difference between parallel execution and an accidental bottleneck is literally one variable.