Skip to main content

repame_sim/
task.rs

1//! A fixed-size job pool for off-thread work.
2//!
3//! Used for the jobs that are naturally owned and independent: asset import,
4//! chunk streaming, navmesh bake, atlas packing. Jobs are `'static` (they own
5//! their inputs and return their output); for work that must borrow a local
6//! slice, use `std::thread::scope` directly — this pool is not a scoped pool.
7//!
8//! Quality-of-life matched to the reference task pools (bevy_tasks, Godot's
9//! `WorkerThreadPool`): a worker that panics carries the panic to the joiner
10//! rather than poisoning the queue or silently dropping the job, and the pool
11//! joins its workers on drop.
12//!
13//! Determinism: a job pool introduces scheduling nondeterminism by nature.
14//! Never let a sim outcome depend on the order in which jobs finish; collect
15//! results and apply them in a fixed order (or in a `Sim` schedule that reads
16//! a completed queue) if the result feeds gameplay.
17
18use std::any::Any;
19use std::collections::VecDeque;
20use std::sync::{Arc, Condvar, Mutex};
21use std::thread::JoinHandle;
22
23use bevy_ecs::prelude::Resource;
24
25type Job = Box<dyn FnOnce() + Send + 'static>;
26
27struct Queue {
28    jobs: VecDeque<Job>,
29    closed: bool,
30}
31
32struct Inner {
33    queue: Mutex<Queue>,
34    signal: Condvar,
35}
36
37enum SlotState<T> {
38    Empty,
39    Done(T),
40    Failed(Box<dyn Any + Send + 'static>),
41}
42
43struct Slot<T> {
44    value: Mutex<SlotState<T>>,
45    ready: Condvar,
46}
47
48/// A handle to a job running on a [`TaskPool`].
49///
50/// Dropping the handle does not cancel the job. `join` waits for it and
51/// re-raises a panic the job raised.
52pub struct Task<T> {
53    slot: Arc<Slot<T>>,
54}
55
56impl<T> Task<T> {
57    /// Wait for the job and return its value, re-raising any panic.
58    pub fn join(self) -> T {
59        let mut guard = self.slot.value.lock().unwrap_or_else(|e| e.into_inner());
60        loop {
61            if !matches!(&*guard, SlotState::Empty) {
62                break;
63            }
64            guard = self
65                .slot
66                .ready
67                .wait(guard)
68                .unwrap_or_else(|e| e.into_inner());
69        }
70        match std::mem::replace(&mut *guard, SlotState::Empty) {
71            SlotState::Done(value) => value,
72            SlotState::Failed(payload) => std::panic::resume_unwind(payload),
73            SlotState::Empty => unreachable!("wait returned on an empty slot"),
74        }
75    }
76
77    /// Whether the job has finished. A finished job may still be panicked.
78    pub fn is_finished(&self) -> bool {
79        !matches!(
80            &*self.slot.value.lock().unwrap_or_else(|e| e.into_inner()),
81            SlotState::Empty
82        )
83    }
84}
85
86/// Fixed-size worker pool. `new(n)` starts `n` worker threads.
87///
88/// Dropping the pool stops the workers once they drain the queue, joins them,
89/// and abandons any job still queued (its [`Task`] never resolves). Keep the
90/// pool alive until the tasks you joined on have finished.
91#[derive(Resource)]
92pub struct TaskPool {
93    inner: Arc<Inner>,
94    workers: Vec<JoinHandle<()>>,
95}
96
97impl TaskPool {
98    /// Start a pool with `threads` workers. At least one worker is started.
99    pub fn new(threads: usize) -> Self {
100        let threads = threads.max(1);
101        let inner = Arc::new(Inner {
102            queue: Mutex::new(Queue {
103                jobs: VecDeque::new(),
104                closed: false,
105            }),
106            signal: Condvar::new(),
107        });
108        let mut workers = Vec::with_capacity(threads);
109        for index in 0..threads {
110            let inner = Arc::clone(&inner);
111            let handle = std::thread::Builder::new()
112                .name(format!("repame-task-{index}"))
113                .spawn(move || worker(inner))
114                .expect("failed to start a task-pool worker");
115            workers.push(handle);
116        }
117        Self { inner, workers }
118    }
119
120    /// A pool sized to the machine's available parallelism, at least one.
121    pub fn auto() -> Self {
122        let threads = std::thread::available_parallelism()
123            .map(|n| n.get())
124            .unwrap_or(1);
125        Self::new(threads)
126    }
127
128    /// Number of workers.
129    pub fn threads(&self) -> usize {
130        self.workers.len()
131    }
132
133    /// Queue a job and return a handle to its result.
134    pub fn spawn<F, T>(&self, job: F) -> Task<T>
135    where
136        F: FnOnce() -> T + Send + 'static,
137        T: Send + 'static,
138    {
139        let slot = Arc::new(Slot {
140            value: Mutex::new(SlotState::Empty),
141            ready: Condvar::new(),
142        });
143        let done = Arc::clone(&slot);
144        self.push(Box::new(move || {
145            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job));
146            let mut guard = done.value.lock().unwrap_or_else(|e| e.into_inner());
147            *guard = match outcome {
148                Ok(value) => SlotState::Done(value),
149                Err(payload) => SlotState::Failed(payload),
150            };
151            done.ready.notify_all();
152        }));
153        Task { slot }
154    }
155
156    /// Queue every job and return their results in input order.
157    pub fn map<I, F, T>(&self, items: I, job: F) -> Vec<T>
158    where
159        I: IntoIterator,
160        F: Fn(I::Item) -> T + Send + Sync + 'static,
161        I::Item: Send + 'static,
162        T: Send + 'static,
163    {
164        let job = Arc::new(job);
165        let mut tasks = Vec::new();
166        for item in items {
167            let job = Arc::clone(&job);
168            tasks.push(self.spawn(move || job(item)));
169        }
170        tasks.into_iter().map(Task::join).collect()
171    }
172
173    fn push(&self, job: Job) {
174        let mut queue = self.inner.queue.lock().unwrap_or_else(|e| e.into_inner());
175        if queue.closed {
176            return;
177        }
178        queue.jobs.push_back(job);
179        self.inner.signal.notify_one();
180    }
181}
182
183impl Drop for TaskPool {
184    fn drop(&mut self) {
185        {
186            let mut queue = self.inner.queue.lock().unwrap_or_else(|e| e.into_inner());
187            queue.closed = true;
188        }
189        self.inner.signal.notify_all();
190        for worker in self.workers.drain(..) {
191            let _ = worker.join();
192        }
193    }
194}
195
196fn worker(inner: Arc<Inner>) {
197    loop {
198        let job = {
199            let mut queue = inner.queue.lock().unwrap_or_else(|e| e.into_inner());
200            loop {
201                if let Some(job) = queue.jobs.pop_front() {
202                    break Some(job);
203                }
204                if queue.closed {
205                    break None;
206                }
207                queue = inner.signal.wait(queue).unwrap_or_else(|e| e.into_inner());
208            }
209        };
210        match job {
211            Some(job) => job(),
212            None => return,
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::sync::atomic::{AtomicUsize, Ordering};
221
222    #[test]
223    fn spawn_joins_to_the_value() {
224        let pool = TaskPool::new(2);
225        assert_eq!(pool.spawn(|| 6 * 7).join(), 42);
226        assert_eq!(pool.threads(), 2);
227    }
228
229    #[test]
230    fn one_worker_is_the_floor() {
231        let pool = TaskPool::new(0);
232        assert_eq!(pool.threads(), 1);
233        assert_eq!(pool.spawn(|| 1 + 1).join(), 2);
234    }
235
236    #[test]
237    fn map_preserves_input_order() {
238        let pool = TaskPool::new(4);
239        let out = pool.map(0..64, |n| n * n);
240        assert_eq!(out, (0..64).map(|n| n * n).collect::<Vec<_>>());
241    }
242
243    #[test]
244    fn jobs_actually_run_off_thread() {
245        let pool = TaskPool::new(3);
246        let counter = Arc::new(AtomicUsize::new(0));
247        let mut tasks = Vec::new();
248        for _ in 0..32 {
249            let counter = Arc::clone(&counter);
250            tasks.push(pool.spawn(move || {
251                counter.fetch_add(1, Ordering::SeqCst);
252            }));
253        }
254        for task in tasks {
255            task.join();
256        }
257        assert_eq!(counter.load(Ordering::SeqCst), 32);
258    }
259
260    #[test]
261    #[should_panic(expected = "boom")]
262    fn a_panicking_job_re_raises_at_the_joiner() {
263        let pool = TaskPool::new(1);
264        pool.spawn(|| panic!("boom")).join();
265    }
266
267    #[test]
268    fn a_panicked_worker_does_not_stop_the_pool() {
269        let pool = TaskPool::new(2);
270        let failed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
271            pool.spawn(|| panic!("boom")).join();
272        }));
273        assert!(failed.is_err());
274        assert_eq!(pool.spawn(|| 3).join(), 3);
275    }
276
277    #[test]
278    fn dropping_the_pool_joins_its_workers() {
279        let pool = TaskPool::new(2);
280        let counter = Arc::new(AtomicUsize::new(0));
281        let c = Arc::clone(&counter);
282        let task = pool.spawn(move || {
283            c.fetch_add(1, Ordering::SeqCst);
284        });
285        task.join();
286        drop(pool);
287        assert_eq!(counter.load(Ordering::SeqCst), 1);
288    }
289}