Some thoughts on parallelism in libraries: who should own the threads?
I've been thinking about how libraries should handle parallelism.
Spawning a few threads internally is easy. Using Rayon is even easier. But is that always the right approach?
What if the application already has its own thread pool? Or wants to control how CPU resources are distributed? While working on mbrotli, I decided to experiment with a different approach.
What if the library knows how to parallelize the work, but doesn't decide how to execute it?
The idea is simple: mbrotli splits compression into independent tasks and hands them back to the caller. The caller executes them however it wants, and mbrotli takes care of assembling the results into a valid Brotli stream.
For example, here's how you can read a file, compress it in parallel using 4 threads, and write the result to another file:
use std::{fs::File, io};
use mbrotli::{EncoderConfig, Quality};
use mbrotli::compressor::parallel::{
ParallelCompressor, ParallelConfig, TaskCount,
};
use mbrotli::io::FinishError;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut input = File::open("input.txt")?;
let output = File::create("input.txt.br")?;
let mut compressor = ParallelCompressor::new(
EncoderConfig::default().with_quality(Quality::Q5),
ParallelConfig::default(),
)?;
std::thread::scope(|scope| {
let (mut writer, queue) =
compressor.writer(output, TaskCount::try_from(4)?)?;
// We decide where the tasks run.
for _ in 0..4 {
let queue = queue.clone();
scope.spawn(move || {
for task in queue {
task.run();
}
});
}
drop(queue);
// Read -> parallel compression -> write
io::copy(&mut input, &mut writer)?;
writer.finish().map_err(FinishError::into_error)?;
Ok::<_, Box<dyn std::error::Error>>(())
})?;
Ok(())
}
Nothing particularly magical here. The library doesn't spawn a single thread. It just provides work to execute.
And that's what I like about this approach.
The same compression API can work with dedicated threads, an existing thread pool, or a custom scheduler. The application remains in control of its resources.
Of course, it's not free. The caller has to ensure tasks actually get executed, and a poorly designed scheduler can easily lead to deadlocks.
But for low level libraries, I think giving up control over scheduling can actually be a good thing.
I'm not saying every library should work this way. Sometimes managing threads internally is perfectly reasonable.
I'm just starting to think that parallelism and thread management don't necessarily have to be the same responsibility.
Curious what other Rust developers think about this. Would you prefer libraries to manage their own parallelism, or expose work to your existing executor?