Skip to main content

pebble/threading/
mod.rs

1//! A small, fixed-size worker pool for offloading CPU-bound work off the
2//! main thread — mip/image processing, physics steps, any one-off or
3//! recurring task that shouldn't block a frame.
4//!
5//! Deliberately NOT a full async runtime: no cancellation, no priorities,
6//! no work-stealing. Just a bounded number of OS threads pulling jobs off
7//! one shared, lock-free MPMC queue, with results delivered back via a
8//! channel you poll from an ordinary system. If you outgrow this — need
9//! cancellation, need priority scheduling — that's real, separate
10//! infrastructure to build once you have a concrete case for it, not
11//! something to guess at now.
12//!
13//! Requires the `crossbeam-channel` crate (lock-free MPMC), since
14//! `std::sync::mpsc` only supports a single consumer and would otherwise
15//! force a `Mutex` around the receiver for multiple worker threads.
16
17use crossbeam_channel::{Receiver as CbReceiver, Sender as CbSender, unbounded};
18use std::sync::mpsc::{Receiver, Sender, channel};
19
20use crate::ecs::plugin::Plugin;
21
22type Job = Box<dyn FnOnce() + Send + 'static>;
23
24/// The worker pool itself. Insert as a resource once, at startup; every
25/// system that needs to offload work reaches for `Res<BackgroundTasks>`
26/// and calls `spawn`.
27pub struct BackgroundTasks {
28    job_tx: CbSender<Job>,
29}
30
31impl BackgroundTasks {
32    /// Spawns `worker_count` OS threads, each pulling jobs off one shared,
33    /// lock-free queue until the pool itself is dropped. A worker count
34    /// around your CPU's core count (minus one, to leave room for the
35    /// main thread) is a reasonable default; tune based on actual
36    /// measured load.
37    pub fn new(worker_count: usize) -> Self {
38        let (job_tx, job_rx): (CbSender<Job>, CbReceiver<Job>) = unbounded();
39
40        for _ in 0..worker_count.max(1) {
41            let job_rx = job_rx.clone(); // cheap — crossbeam receivers are natively Clone, no Mutex needed
42            std::thread::spawn(move || {
43                // `recv()` blocks this worker thread only, until a job
44                // arrives or every sender (the pool, plus any clones) is
45                // dropped — no lock contention between workers picking up
46                // jobs concurrently.
47                while let Ok(job) = job_rx.recv() {
48                    job();
49                }
50            });
51        }
52
53        Self { job_tx }
54    }
55
56    /// Queue `work` to run on the pool. Returns a [`TaskHandle`] you can
57    /// poll (non-blocking) from any system to check whether it's done.
58    ///
59    /// `work` runs on whichever worker thread picks it up next — don't
60    /// assume anything about timing or ordering relative to other spawned
61    /// tasks unless you build that coordination yourself.
62    pub fn spawn<T: Send + 'static>(
63        &self,
64        work: impl FnOnce() -> T + Send + 'static,
65    ) -> TaskHandle<T> {
66        let (result_tx, result_rx) = channel::<T>();
67        let job: Job = Box::new(move || {
68            let result = work();
69            let _ = result_tx.send(result); // ignore: receiver may have been dropped, that's fine
70        });
71        // If this fails, every worker thread has panicked and the pool is
72        // effectively dead — surfaced as a permanently-pending TaskHandle
73        // rather than a panic here, since a dead pool shouldn't crash an
74        // unrelated caller trying to queue new work.
75        let _ = self.job_tx.send(job);
76        TaskHandle { rx: result_rx }
77    }
78}
79
80/// A handle to a single in-flight (or already-finished) task. Poll it
81/// from an ordinary system with [`try_recv`](TaskHandle::try_recv) —
82/// never blocks.
83pub struct TaskHandle<T> {
84    rx: Receiver<T>,
85}
86
87impl<T> TaskHandle<T> {
88    /// Returns `Some(result)` once the task has finished, `None`
89    /// otherwise. Never blocks — safe to call every tick.
90    pub fn try_recv(&mut self) -> Option<T> {
91        self.rx.try_recv().ok()
92    }
93}
94
95/// Registers `BackgroundTasks` as a resource with the given worker count.
96///
97/// ```ignore
98/// app.add_plugin(BackgroundTasksPlugin::new(4));
99/// ```
100pub struct BackgroundTasksPlugin {
101    worker_count: usize,
102}
103
104impl BackgroundTasksPlugin {
105    pub fn new(worker_count: usize) -> Self {
106        Self { worker_count }
107    }
108}
109
110impl Plugin for BackgroundTasksPlugin {
111    fn build(&self, app: &mut crate::prelude::App) {
112        app.add_resource(BackgroundTasks::new(self.worker_count));
113    }
114}