Skip to main content

otf_pixels_core/
pool.rs

1//! The thread pool.
2//!
3//! Everything here is ours, the queue included (ADR-0017, superseding
4//! ADR-0008's `crossbeam-deque`).
5//!
6//! # Shape
7//!
8//! One FIFO queue, shared by every worker. Tasks only ever come from outside
9//! the pool — a scheduler wave submits about one tile per worker — so there
10//! is nothing for per-worker queues and stealing to balance, and handing
11//! tiles out in submission order is also what keeps neighbouring tiles on
12//! the same wave. Measured against `crossbeam-deque`'s work-stealing deques
13//! on the same scheduler, this is level on strips and concurrent jobs and 17%
14//! faster on a tiled blur. The queue is a `VecDeque` behind a lock: a
15//! lock-free one cannot be written without `unsafe`, which every crate
16//! forbids, and a task is a whole tile, so a lock held for one push or pop is
17//! noise beside it.
18//!
19//! # Panics are contained, not propagated
20//!
21//! A panicking task must not poison the pool or abort the process
22//! (ARCHITECTURE §Failure model). Tasks are caught, and the panic is reported
23//! to the submitter as a [`PixelsError`] rather than resumed. Ops are written
24//! to return errors, so a panic here means a defect — but a defect in one tile
25//! still must not take the host process down with it.
26
27use crate::{PixelsError, Result};
28use std::collections::VecDeque;
29use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
30use std::sync::{Arc, Condvar, Mutex, MutexGuard};
31
32/// A unit of work for the pool.
33type Task = Box<dyn FnOnce() + Send>;
34
35/// How long an idle worker sleeps before looking again.
36///
37/// Correctness does not depend on it: a worker parks only after finding the
38/// queue empty under the parking lock, and a submitter pushes before
39/// signalling under that same lock, so a wakeup cannot be missed. It is a
40/// backstop, long enough that an idle pool living as long as the process
41/// costs nothing measurable.
42const IDLE_PARK: std::time::Duration = std::time::Duration::from_secs(1);
43
44thread_local! {
45    /// Whether this thread is a worker of some [`ThreadPool`].
46    static ON_WORKER: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
47}
48
49/// Shared state every worker sees.
50struct Shared {
51    /// Tasks waiting for a worker, oldest first.
52    queue: Mutex<VecDeque<Task>>,
53    /// How many workers the pool runs.
54    workers: usize,
55    /// Set once to tell workers to wind down.
56    shutdown: AtomicBool,
57    /// Tasks queued or running; shutdown waits for it to reach zero.
58    pending: AtomicUsize,
59    /// Parking lot for idle workers.
60    idle: Mutex<()>,
61    wake: Condvar,
62}
63
64impl Shared {
65    fn queue(&self) -> MutexGuard<'_, VecDeque<Task>> {
66        // A task never runs under this lock, so a poisoned one only means a
67        // push or pop was interrupted; the queue itself is still whole.
68        self.queue
69            .lock()
70            .unwrap_or_else(std::sync::PoisonError::into_inner)
71    }
72
73    /// Wake one parked worker, if any.
74    fn signal_one(&self) {
75        // The lock is taken so a worker cannot find the queue empty, decide
76        // to park, and miss this notification in between.
77        let _guard = self
78            .idle
79            .lock()
80            .unwrap_or_else(std::sync::PoisonError::into_inner);
81        self.wake.notify_one();
82    }
83
84    /// Wake every parked worker.
85    fn signal_all(&self) {
86        let _guard = self
87            .idle
88            .lock()
89            .unwrap_or_else(std::sync::PoisonError::into_inner);
90        self.wake.notify_all();
91    }
92}
93
94/// A pool of worker threads sharing one task queue.
95///
96/// Dropping the pool signals shutdown and joins every worker, so no task
97/// outlives it.
98#[derive(Debug)]
99pub struct ThreadPool {
100    shared: Arc<Shared>,
101    workers: Vec<std::thread::JoinHandle<()>>,
102    threads: usize,
103}
104
105impl std::fmt::Debug for Shared {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        f.debug_struct("Shared")
108            .field("workers", &self.workers)
109            .field("pending", &self.pending.load(Ordering::Relaxed))
110            .finish_non_exhaustive()
111    }
112}
113
114impl ThreadPool {
115    /// Build a pool with `threads` workers.
116    ///
117    /// `threads` is clamped to at least one. Use [`ThreadPool::default_threads`]
118    /// for the machine's parallelism.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`PixelsError::Io`] if the operating system refuses to spawn a
123    /// worker thread.
124    pub fn new(threads: usize) -> Result<Self> {
125        let threads = threads.max(1);
126        let shared = Arc::new(Shared {
127            queue: Mutex::new(VecDeque::new()),
128            workers: threads,
129            shutdown: AtomicBool::new(false),
130            pending: AtomicUsize::new(0),
131            idle: Mutex::new(()),
132            wake: Condvar::new(),
133        });
134
135        let mut workers = Vec::with_capacity(threads);
136        for index in 0..threads {
137            let shared = Arc::clone(&shared);
138            let handle = std::thread::Builder::new()
139                .name(format!("otf-pixels-worker-{index}"))
140                .spawn(move || worker_loop(&shared))
141                .map_err(|e| PixelsError::io("spawning a scheduler worker thread", e))?;
142            workers.push(handle);
143        }
144        Ok(Self {
145            shared,
146            workers,
147            threads,
148        })
149    }
150
151    /// The parallelism to use when the caller has no preference.
152    ///
153    /// Falls back to one thread where the platform cannot report it, which
154    /// yields a correct if serial engine rather than a failure.
155    #[must_use]
156    pub fn default_threads() -> usize {
157        std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
158    }
159
160    /// A pool sized to [`ThreadPool::default_threads`].
161    ///
162    /// # Errors
163    ///
164    /// As [`ThreadPool::new`].
165    pub fn with_default_threads() -> Result<Self> {
166        Self::new(Self::default_threads())
167    }
168
169    /// Whether the calling thread is a worker of any pool.
170    ///
171    /// A run blocks its caller until its tiles are done, so a run started
172    /// from inside a task would hold a worker of the pool it is waiting on.
173    /// Callers that would otherwise use a shared pool check this first.
174    #[must_use]
175    pub fn on_worker_thread() -> bool {
176        ON_WORKER.with(std::cell::Cell::get)
177    }
178
179    /// How many workers this pool runs.
180    #[must_use]
181    pub const fn threads(&self) -> usize {
182        self.threads
183    }
184
185    /// Queue `task` for execution.
186    ///
187    /// Returns immediately. Panics inside `task` are contained by the worker
188    /// and do not propagate here.
189    pub fn spawn(&self, task: impl FnOnce() + Send + 'static) {
190        self.shared.pending.fetch_add(1, Ordering::SeqCst);
191        self.shared.queue().push_back(Box::new(task));
192        self.shared.signal_one();
193    }
194
195    /// Run `tasks` on the pool and return once every one has finished.
196    ///
197    /// # Why `'static`
198    ///
199    /// Tasks must be `'static` because they run on long-lived worker threads
200    /// that outlive this call. Erasing a shorter lifetime is what `rayon` uses
201    /// `unsafe` for, and ADR-0008 keeps `unsafe_code = "forbid"` in our
202    /// crates. This costs the scheduler nothing: tiles are already
203    /// `Arc<TileBuf>` and graph nodes `Arc<Node>`, so a task moves cheap
204    /// handles rather than borrowing.
205    ///
206    /// The calling thread blocks. Calling `run_all` from *inside* a pool task
207    /// is not supported and can deadlock; the scheduler submits one flat batch
208    /// per wave instead of nesting.
209    ///
210    /// # Errors
211    ///
212    /// Returns the failing task's error, choosing the **lowest-indexed**
213    /// failure when several fail. Which worker happens to fail first is a race;
214    /// which task index is lowest is not, so the reported error is
215    /// deterministic (SPEC §Guarantees 2).
216    pub fn run_all<F>(&self, tasks: Vec<F>) -> Result<()>
217    where
218        F: FnOnce() -> Result<()> + Send + 'static,
219    {
220        if tasks.is_empty() {
221            return Ok(());
222        }
223        let count = tasks.len();
224        let batch = Arc::new(Batch::new(count));
225        // The whole wave is queued under one lock and then as many workers
226        // woken as it can use, rather than a lock and a wakeup per task.
227        self.shared.pending.fetch_add(count, Ordering::SeqCst);
228        {
229            let mut queue = self.shared.queue();
230            for (index, task) in tasks.into_iter().enumerate() {
231                let batch = Arc::clone(&batch);
232                queue.push_back(Box::new(move || {
233                    let outcome = catch(task);
234                    batch.finish(index, outcome);
235                }));
236            }
237        }
238        if count >= self.shared.workers {
239            self.shared.signal_all();
240        } else {
241            for _ in 0..count {
242                self.shared.signal_one();
243            }
244        }
245        batch.wait();
246        batch.first_error()
247    }
248}
249
250/// Completion tracking for one [`ThreadPool::run_all`] batch.
251#[derive(Debug)]
252struct Batch {
253    /// One slot per task, so failures can be ranked by task index rather than
254    /// by which worker got there first.
255    slots: Vec<Mutex<Option<PixelsError>>>,
256    remaining: AtomicUsize,
257    finished: Mutex<bool>,
258    complete: Condvar,
259}
260
261impl Batch {
262    fn new(count: usize) -> Self {
263        Self {
264            slots: (0..count).map(|_| Mutex::new(None)).collect(),
265            remaining: AtomicUsize::new(count),
266            finished: Mutex::new(false),
267            complete: Condvar::new(),
268        }
269    }
270
271    /// Record one task's outcome and wake the waiter if it was the last.
272    fn finish(&self, index: usize, outcome: Result<()>) {
273        if let Err(error) = outcome {
274            if let Some(slot) = self.slots.get(index) {
275                *slot
276                    .lock()
277                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(error);
278            }
279        }
280        if self.remaining.fetch_sub(1, Ordering::SeqCst) == 1 {
281            let mut finished = self
282                .finished
283                .lock()
284                .unwrap_or_else(std::sync::PoisonError::into_inner);
285            *finished = true;
286            self.complete.notify_all();
287        }
288    }
289
290    /// Block until every task in the batch has finished.
291    fn wait(&self) {
292        let mut finished = self
293            .finished
294            .lock()
295            .unwrap_or_else(std::sync::PoisonError::into_inner);
296        while !*finished {
297            finished = self
298                .complete
299                .wait(finished)
300                .unwrap_or_else(std::sync::PoisonError::into_inner);
301        }
302    }
303
304    /// The lowest-indexed failure, if any task failed.
305    fn first_error(&self) -> Result<()> {
306        for slot in &self.slots {
307            let mut slot = slot
308                .lock()
309                .unwrap_or_else(std::sync::PoisonError::into_inner);
310            if let Some(error) = slot.take() {
311                return Err(error);
312            }
313        }
314        Ok(())
315    }
316}
317
318/// Run `task`, converting a panic into an error.
319fn catch<F: FnOnce() -> Result<()>>(task: F) -> Result<()> {
320    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(task)) {
321        Ok(result) => result,
322        Err(payload) => {
323            let detail = panic_message(payload.as_ref());
324            Err(PixelsError::graph(format!(
325                "a scheduler task panicked: {detail}"
326            )))
327        }
328    }
329}
330
331/// Best-effort text of a panic payload.
332fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
333    if let Some(text) = payload.downcast_ref::<&str>() {
334        return (*text).to_owned();
335    }
336    if let Some(text) = payload.downcast_ref::<String>() {
337        return text.clone();
338    }
339    "non-string panic payload".to_owned()
340}
341
342/// The body of each worker thread.
343fn worker_loop(shared: &Arc<Shared>) {
344    ON_WORKER.with(|flag| flag.set(true));
345    loop {
346        if shared.shutdown.load(Ordering::SeqCst) && shared.pending.load(Ordering::SeqCst) == 0 {
347            return;
348        }
349        // Bound before matching, so the queue lock is released before the
350        // task runs.
351        let next = shared.queue().pop_front();
352        if let Some(task) = next {
353            // A panicking task must not unwind out of the worker thread.
354            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(task));
355            shared.pending.fetch_sub(1, Ordering::SeqCst);
356            continue;
357        }
358        // Nothing queued: park until a submission signals. The queue is
359        // checked again under the parking lock, because a task pushed since
360        // the pop above has already sent its signal.
361        let guard = shared
362            .idle
363            .lock()
364            .unwrap_or_else(std::sync::PoisonError::into_inner);
365        if !shared.queue().is_empty() {
366            continue;
367        }
368        // Shutting down with nothing queued: tasks still running belong to
369        // other workers, and this one has nothing left to do.
370        if shared.shutdown.load(Ordering::SeqCst) {
371            return;
372        }
373        let _unused = shared
374            .wake
375            .wait_timeout(guard, IDLE_PARK)
376            .unwrap_or_else(std::sync::PoisonError::into_inner);
377    }
378}
379
380impl Drop for ThreadPool {
381    fn drop(&mut self) {
382        self.shared.shutdown.store(true, Ordering::SeqCst);
383        self.shared.signal_all();
384        for handle in self.workers.drain(..) {
385            // A worker that panicked has already been contained; joining it
386            // is still correct and must not panic the dropping thread.
387            let _ = handle.join();
388        }
389    }
390}
391
392#[cfg(test)]
393#[allow(
394    clippy::unwrap_used,
395    clippy::indexing_slicing,
396    clippy::panic,
397    reason = "tests operate on known-good values and assert shapes directly"
398)]
399mod tests {
400    use super::*;
401
402    /// A shared counter, the `'static` shape every pool task uses.
403    fn counter() -> Arc<AtomicUsize> {
404        Arc::new(AtomicUsize::new(0))
405    }
406
407    #[test]
408    fn workers_know_they_are_workers() {
409        assert!(!ThreadPool::on_worker_thread());
410        let pool = ThreadPool::new(2).unwrap();
411        let seen = Arc::new(AtomicBool::new(false));
412        let flag = Arc::clone(&seen);
413        pool.run_all(vec![move || {
414            flag.store(ThreadPool::on_worker_thread(), Ordering::SeqCst);
415            Ok(())
416        }])
417        .unwrap();
418        assert!(seen.load(Ordering::SeqCst));
419        assert!(!ThreadPool::on_worker_thread());
420    }
421
422    #[test]
423    fn an_idle_pool_wakes_for_new_work_rather_than_polling_for_it() {
424        // Long enough for every worker to park on `IDLE_PARK`. If submitting
425        // did not wake one, the task would wait out the full park.
426        let pool = ThreadPool::new(2).unwrap();
427        std::thread::sleep(std::time::Duration::from_millis(50));
428        let started = std::time::Instant::now();
429        pool.run_all(vec![|| Ok(())]).unwrap();
430        assert!(
431            started.elapsed() < IDLE_PARK / 2,
432            "an idle pool took {:?} to run one task",
433            started.elapsed()
434        );
435    }
436
437    #[test]
438    fn every_task_runs_exactly_once() {
439        let pool = ThreadPool::new(4).unwrap();
440        let count = counter();
441        let tasks: Vec<_> = (0..1000)
442            .map(|_| {
443                let count = Arc::clone(&count);
444                move || {
445                    count.fetch_add(1, Ordering::Relaxed);
446                    Ok(())
447                }
448            })
449            .collect();
450        pool.run_all(tasks).unwrap();
451        assert_eq!(count.load(Ordering::Relaxed), 1000);
452    }
453
454    #[test]
455    fn tasks_share_state_through_arcs() {
456        // The shape the scheduler uses: tiles are Arc<TileBuf>, so a task
457        // moves handles rather than borrowing from the caller's frame.
458        let pool = ThreadPool::new(4).unwrap();
459        let data: Arc<Vec<usize>> = Arc::new((0..100).collect());
460        let total = counter();
461        let tasks: Vec<_> = (0..10)
462            .map(|chunk| {
463                let (data, total) = (Arc::clone(&data), Arc::clone(&total));
464                move || {
465                    let sum: usize = data[chunk * 10..(chunk + 1) * 10].iter().sum();
466                    total.fetch_add(sum, Ordering::Relaxed);
467                    Ok(())
468                }
469            })
470            .collect();
471        pool.run_all(tasks).unwrap();
472        assert_eq!(total.load(Ordering::Relaxed), (0..100).sum::<usize>());
473    }
474
475    #[test]
476    fn the_lowest_indexed_failure_is_reported() {
477        // Determinism (SPEC §Guarantees 2): whichever worker fails first, the
478        // reported error is always the lowest-indexed failing task.
479        let pool = ThreadPool::new(8).unwrap();
480        for attempt in 0..25 {
481            let tasks: Vec<_> = (0..64)
482                .map(|i| {
483                    move || {
484                        if i == 5 || i == 40 {
485                            return Err(PixelsError::malformed("test", format!("task {i}")));
486                        }
487                        Ok(())
488                    }
489                })
490                .collect();
491            let err = pool.run_all(tasks).unwrap_err();
492            assert!(
493                err.to_string().contains("task 5"),
494                "attempt {attempt}: {err}"
495            );
496        }
497    }
498
499    #[test]
500    fn a_panicking_task_becomes_an_error_not_an_abort() {
501        let pool = ThreadPool::new(4).unwrap();
502        let tasks: Vec<_> = (0..8)
503            .map(|i| {
504                move || {
505                    assert!(i != 3, "kernel defect");
506                    Ok(())
507                }
508            })
509            .collect();
510        let err = pool.run_all(tasks).unwrap_err();
511        assert_eq!(err.code(), crate::ErrorCode::Graph);
512        assert!(err.to_string().contains("panicked"), "got: {err}");
513        assert!(err.to_string().contains("kernel defect"), "got: {err}");
514
515        // The pool is still usable afterwards: one bad tile does not kill it.
516        let count = counter();
517        let c = Arc::clone(&count);
518        pool.run_all(vec![move || {
519            c.fetch_add(1, Ordering::Relaxed);
520            Ok(())
521        }])
522        .unwrap();
523        assert_eq!(count.load(Ordering::Relaxed), 1);
524    }
525
526    #[test]
527    fn a_single_threaded_pool_still_completes() {
528        // The caller blocks, so a one-worker pool must not deadlock.
529        let pool = ThreadPool::new(1).unwrap();
530        let count = counter();
531        let tasks: Vec<_> = (0..100)
532            .map(|_| {
533                let count = Arc::clone(&count);
534                move || {
535                    count.fetch_add(1, Ordering::Relaxed);
536                    Ok(())
537                }
538            })
539            .collect();
540        pool.run_all(tasks).unwrap();
541        assert_eq!(count.load(Ordering::Relaxed), 100);
542        assert_eq!(pool.threads(), 1);
543    }
544
545    #[test]
546    fn zero_threads_is_clamped_to_one() {
547        assert_eq!(ThreadPool::new(0).unwrap().threads(), 1);
548    }
549
550    #[test]
551    fn an_empty_batch_is_a_no_op() {
552        let pool = ThreadPool::new(2).unwrap();
553        let tasks: Vec<fn() -> Result<()>> = Vec::new();
554        pool.run_all(tasks).unwrap();
555    }
556
557    #[test]
558    fn repeated_batches_reuse_the_same_workers() {
559        // Worker threads are long-lived; a pipeline runs many waves.
560        let pool = ThreadPool::new(4).unwrap();
561        let count = counter();
562        for _ in 0..50 {
563            let tasks: Vec<_> = (0..20)
564                .map(|_| {
565                    let count = Arc::clone(&count);
566                    move || {
567                        count.fetch_add(1, Ordering::Relaxed);
568                        Ok(())
569                    }
570                })
571                .collect();
572            pool.run_all(tasks).unwrap();
573        }
574        assert_eq!(count.load(Ordering::Relaxed), 1000);
575    }
576
577    #[test]
578    fn outstanding_spawned_work_completes_before_drop() {
579        let done = counter();
580        {
581            let pool = ThreadPool::new(4).unwrap();
582            for _ in 0..200 {
583                let done = Arc::clone(&done);
584                pool.spawn(move || {
585                    done.fetch_add(1, Ordering::Relaxed);
586                });
587            }
588            // Dropping drains outstanding work, then joins.
589        }
590        assert_eq!(done.load(Ordering::Relaxed), 200);
591    }
592
593    #[test]
594    fn default_threads_is_at_least_one() {
595        assert!(ThreadPool::default_threads() >= 1);
596        assert!(ThreadPool::with_default_threads().unwrap().threads() >= 1);
597    }
598
599    #[test]
600    fn work_is_actually_distributed_across_workers() {
601        // Not merely "it completes": prove more than one thread ran tasks.
602        // The M2 scaling benchmark is meaningless if this does not hold.
603        let pool = ThreadPool::new(4).unwrap();
604        let seen: Arc<Mutex<std::collections::HashSet<std::thread::ThreadId>>> =
605            Arc::new(Mutex::new(std::collections::HashSet::new()));
606        let tasks: Vec<_> = (0..200)
607            .map(|_| {
608                let seen = Arc::clone(&seen);
609                move || {
610                    // Each task takes real time, so one worker cannot drain
611                    // the whole wave before its peers wake. A computed delay
612                    // is no good here: an optimised build folds it away, and
613                    // the first worker then legitimately runs everything.
614                    std::thread::sleep(std::time::Duration::from_micros(100));
615                    seen.lock().unwrap().insert(std::thread::current().id());
616                    Ok(())
617                }
618            })
619            .collect();
620        pool.run_all(tasks).unwrap();
621        let count = seen.lock().unwrap().len();
622        assert!(
623            count > 1,
624            "all work ran on one thread; the queue is not being shared"
625        );
626    }
627
628    #[test]
629    fn nested_arcs_keep_results_alive_across_batches() {
630        // Results produced in one wave feed the next, as tiles do.
631        let pool = ThreadPool::new(4).unwrap();
632        let stage1: Arc<Mutex<Vec<u64>>> = Arc::new(Mutex::new(vec![0; 16]));
633        let tasks: Vec<_> = (0..16_u64)
634            .map(|i| {
635                let out = Arc::clone(&stage1);
636                move || {
637                    out.lock().unwrap()[i as usize] = i * 2;
638                    Ok(())
639                }
640            })
641            .collect();
642        pool.run_all(tasks).unwrap();
643
644        let total = Arc::new(AtomicUsize::new(0));
645        let tasks: Vec<_> = (0..16_usize)
646            .map(|i| {
647                let (input, total) = (Arc::clone(&stage1), Arc::clone(&total));
648                move || {
649                    let v = input.lock().unwrap()[i];
650                    total.fetch_add(v as usize, Ordering::Relaxed);
651                    Ok(())
652                }
653            })
654            .collect();
655        pool.run_all(tasks).unwrap();
656        assert_eq!(
657            total.load(Ordering::Relaxed),
658            (0..16).map(|i| i * 2).sum::<usize>()
659        );
660    }
661}