๐ micropool: low-latency thread pool with parallel iterators
micropool is a rayon-style thread pool designed for games and other low-latency scenarios. It implements the ability to spread work across multiple CPU threads in blocking and non-blocking ways. It also has full support for paralight's parallel iterators, which cleanly facilitate multithreading in a synchronous codebase. micropool uses a work-stealing scheduling system, but is unique in several aspects:
- ๐งต๐ค External threads participate: when a non-pool thread is blocked on
micropool(from callingjoinor using a parallel iterator), it will actively help complete the work. This eliminates the overhead of a context switch. - โฉโ
Forward progress is guaranteed: any synchronous work that uses
micropoolis guaranteed to have at least one thread processing it, from the moment of creation. - ๐ฏ๐ก๏ธ Scope-based work stealing: a thread that is blocked will only steal work related to its current task. Blocked threads will never steal unrelated work, which might take an unpredictable amount of time to finish.
- ๐๏ธโก Two priority tiers: foreground work created by a blocking call is always prioritized over background tasks created via
spawn. - ๐๐ค Spinning before sleeping: threads will spin for a configurable interval before sleeping with the operating system scheduler.
Usage
Foreground work with join
A single operation can be split between two threads using the join primitive:
join;
// Possible output:
// B ThreadId(2)
// A ThreadId(1)
Foreground work with parallel iterators
Parallel iterators allow for splitting common list operations across multiple threads. micropool re-exports the paralight library:
use *;
let len = 10_000;
let input = .;
let input_slice = input.as_slice;
let result = input_slice
.par_iter
.with_thread_pool
.;
assert_eq!;
The .with_thread_pool line specifies that the current micropool instance should be used, and split_by_threads indicates that each pool thread should process an equal-sized chunk of the data. Other data-splitting strategies available are split_by, split_per_item, and split_per.
Background work with spawn
Tasks can be spawned asynchronously, then joined later:
let task = spawn_owned;
println!;
println!;
// Possible output:
// Is my task complete yet? false
// The result: 4
Scheduling system
The following example illustrates the properties of the micropool scheduling system:
println!;
let background_task = spawn_owned;
join;
One possible output of this code might be:
A ThreadId(1) // The main thread is #1
D ThreadId(2) // Thread #2 begins helping the outer micropool::join call
C ThreadId(1) // Thread #1 helps to finish the outer micropool::join call
F ThreadId(1) // Thread #1 steals work from thread #2, to help complete the inner micropool::join call
E ThreadId(2) // Thread #2 finishes the inner micropool::join call
B ThreadId(2) // Thread #2 grabs and completes the background task; thread #1 will *never* execute this
There are several key differences between micropool's behavior and rayon, for instance:
- The outer call to
joinoccurs on an external thread. Withrayon, this call would simply block and the main thread would wait for pool threads to finish both halves ofjoin. Withmicropool, the external thread helps. - Because the calling thread always helps complete its work, progress on a synchronous task never stalls. In contrast, if the
rayonthread pool is saturated with tasks, the call tojoinmight be long and unpredictable - therayonworkers would need to finish their current tasks first, even if those tasks are unrelated. - When the external thread finishes its work, and is blocking on the result of
join, there is other work available: thebackground_task. In this case, completion ofbackground_taskis not required forjointo return. As such, the external thread will never run it. In contrast, if arayonthread is blocked, it may run unrelated work in the meantime, so it may take a long/unpredictable amount of time before control flow returns from thejoin. - Worker threads will always help with synchronous work (like
join) before processing asynchronous tasks created viaspawn. This natural separation of foreground and background work ensures that the most important foreground tasks - like per-frame rendering or physics in a agame engine - happen first. - According to Dennis Gustafsson, workers that spin while waiting for new tasks sometimes perform better than workers that sleep. When many short tasks are scheduled, the overhead of operating system calls for sleeping can outweight the wasted compute.
micropoolcompensates for this by spinning threads before they sleep.