Skip to main content

turbo_tasks/
scope_bounded.rs

1//! Bounded scoped parallelism: the number of tasks is known before the scope starts.
2//!
3//! [`scope_bounded`] takes that count up front, hands the caller a [`Scope`] to spawn each task
4//! onto, and returns their results as an iterator. Tasks are closures and may borrow from the
5//! enclosing scope (`'env`); the scope blocks until every one has finished, which is what makes
6//! those borrows sound.
7//!
8//! Use [`scope_unbounded`](crate::scope_unbounded::scope_unbounded) instead when a running job can
9//! discover more work, so the total isn't known up front.
10
11use std::{
12    any::Any,
13    marker::PhantomData,
14    num::NonZeroUsize,
15    panic::{self, AssertUnwindSafe, catch_unwind},
16    sync::{
17        Arc,
18        atomic::{AtomicUsize, Ordering},
19        mpmc::{self, Receiver, Sender},
20    },
21    thread::{self, Thread},
22    time::{Duration, Instant},
23};
24
25use parking_lot::Mutex;
26use tokio::{runtime::Handle, task::block_in_place};
27use tracing::{Span, info_span};
28
29use crate::{TurboTasksApi, manager::try_turbo_tasks, turbo_tasks_scope};
30
31/// A job placed on the work queue: its result-slot index and the closure to run.
32type WorkQueueJob = (usize, Box<dyn FnOnce() + Send + 'static>);
33
34struct ScopeInner {
35    main_thread: Thread,
36    remaining_tasks: AtomicUsize,
37    /// The first panic that occurred in the tasks, by task index.
38    /// The usize value is the index of the task.
39    panic: Mutex<Option<(Box<dyn Any + Send + 'static>, usize)>>,
40    /// Receiving end of the work queue, shared by every drainer. Dropping the `Scope`'s sender is
41    /// what signals that no more jobs are coming.
42    work_queue: Receiver<WorkQueueJob>,
43}
44
45impl ScopeInner {
46    fn on_task_finished(&self, panic: Option<(Box<dyn Any + Send + 'static>, usize)>) {
47        if let Some((err, index)) = panic {
48            let mut old_panic = self.panic.lock();
49            if old_panic.as_ref().is_none_or(|&(_, i)| i > index) {
50                *old_panic = Some((err, index));
51            }
52        }
53        if self.remaining_tasks.fetch_sub(1, Ordering::Release) == 1 {
54            self.main_thread.unpark();
55        }
56    }
57
58    fn wait(&self) {
59        if self.remaining_tasks.load(Ordering::Acquire) == 0 {
60            return;
61        }
62
63        let _span = info_span!("blocking").entered();
64
65        // Park up to 1ms without block_in_place to avoid the overhead.
66        const TIMEOUT: Duration = Duration::from_millis(1);
67        let beginning_park = Instant::now();
68
69        let mut timeout_remaining = TIMEOUT;
70        loop {
71            thread::park_timeout(timeout_remaining);
72            if self.remaining_tasks.load(Ordering::Acquire) == 0 {
73                return;
74            }
75            let elapsed = beginning_park.elapsed();
76            if elapsed >= TIMEOUT {
77                break;
78            }
79            timeout_remaining = TIMEOUT - elapsed;
80        }
81
82        // Park with block_in_place to allow to continue other work
83        block_in_place(|| {
84            while self.remaining_tasks.load(Ordering::Acquire) != 0 {
85                thread::park();
86            }
87        });
88    }
89
90    fn wait_and_rethrow_panic(&self) {
91        self.wait();
92        if let Some((err, _)) = self.panic.lock().take() {
93            panic::resume_unwind(err);
94        }
95    }
96
97    /// Pulls jobs from the shared work queue and runs them until the queue is closed and drained,
98    /// recording any panic. Both the opportunistic helper worker tasks and the calling thread (via
99    /// `Scope::drop`) run this.
100    fn run_jobs(&self) {
101        while let Ok((index, job)) = self.work_queue.recv() {
102            let result = catch_unwind(AssertUnwindSafe(job));
103            let panic = result.err().map(|e| (e, index));
104            self.on_task_finished(panic);
105        }
106    }
107}
108
109/// Scope to allow spawning tasks with a limited lifetime.
110///
111/// Dropping this Scope will wait for all tasks to complete.
112pub struct Scope<'scope, 'env: 'scope, R: Send + 'env> {
113    results: &'scope [Mutex<Option<R>>],
114    index: AtomicUsize,
115    inner: Arc<ScopeInner>,
116    /// Sending end of the work queue. The only sender; `Drop` takes it to close the queue.
117    work_queue: Option<Sender<WorkQueueJob>>,
118    handle: Handle,
119    /// Max number of threads to use, threads are only spawned when needed. The calling thread
120    /// counts towards this budget, so we spawn at most `worker_tasks - 1` helpers.
121    worker_tasks: NonZeroUsize,
122    turbo_tasks: Option<Arc<dyn TurboTasksApi>>,
123    span: Span,
124    /// Invariance over 'env, to make sure 'env cannot shrink, which is necessary for soundness.
125    ///
126    /// See the comment in the stdlib implementation:
127    /// <https://github.com/rust-lang/rust/blob/3b1b0ef4d8/library/std/src/thread/scoped.rs#L12-L33>
128    env: PhantomData<&'env mut &'env ()>,
129}
130
131impl<'scope, 'env: 'scope, R: Send + 'env> Scope<'scope, 'env, R> {
132    /// Creates a new scope.
133    ///
134    /// # Safety
135    ///
136    /// The caller must ensure `Scope` is dropped and not forgotten.
137    unsafe fn new(results: &'scope [Mutex<Option<R>>]) -> Self {
138        let handle = Handle::current();
139        // Never use more threads than there are jobs, or than the runtime has workers.
140        let worker_tasks = NonZeroUsize::new(handle.metrics().num_workers().min(results.len()))
141            .unwrap_or(NonZeroUsize::MIN);
142        let (sender, receiver) = mpmc::channel();
143        Self {
144            results,
145            index: AtomicUsize::new(0),
146            inner: Arc::new(ScopeInner {
147                main_thread: thread::current(),
148                remaining_tasks: AtomicUsize::new(0),
149                panic: Mutex::new(None),
150                work_queue: receiver,
151            }),
152            work_queue: Some(sender),
153            handle,
154            worker_tasks,
155            turbo_tasks: try_turbo_tasks(),
156            span: Span::current(),
157            env: PhantomData,
158        }
159    }
160
161    /// Spawns a new task in the scope.
162    pub fn spawn<F>(&self, f: F)
163    where
164        F: FnOnce() -> R + Send + 'env,
165    {
166        let index = self.index.fetch_add(1, Ordering::Relaxed);
167        assert!(index < self.results.len(), "Too many tasks spawned");
168        let result_cell: &Mutex<Option<R>> = &self.results[index];
169
170        let turbo_tasks = self.turbo_tasks.clone();
171        let f: Box<dyn FnOnce() + Send + 'scope> = Box::new(|| {
172            let result = {
173                if let Some(turbo_tasks) = turbo_tasks {
174                    // Ensure that the turbo tasks context is maintained across the job.
175                    turbo_tasks_scope(turbo_tasks, f)
176                } else {
177                    // If no turbo tasks context is available, just run the job.
178                    f()
179                }
180            };
181            *result_cell.lock() = Some(result);
182        });
183        let f: *mut (dyn FnOnce() + Send + 'scope) = Box::into_raw(f);
184
185        // SAFETY: Scope ensures (e. g. in Drop) that spawned tasks is awaited before the
186        // lifetime `'env` ends.
187        let f = unsafe {
188            std::mem::transmute::<
189                *mut (dyn FnOnce() + Send + 'scope),
190                *mut (dyn FnOnce() + Send + 'static),
191            >(f)
192        };
193
194        // SAFETY: We just called `Box::into_raw`.
195        let f = unsafe { Box::from_raw(f) };
196
197        self.inner.remaining_tasks.fetch_add(1, Ordering::Relaxed);
198
199        // Add to the shared work queue, all threads read from this. Neither failure is reachable,
200        // but a job silently dropped here would leave `remaining_tasks` above zero and hang the
201        // scope, so panic instead.
202        self.work_queue
203            .as_ref()
204            .expect("sender is only taken in Drop")
205            .send((index, f))
206            .expect("receiver is owned by inner and outlives the scope");
207
208        // Spawn a tokio worker for each job until we hit the max `worker_tasks`.
209        if index < self.worker_tasks.get() - 1 {
210            let inner = self.inner.clone();
211            let span = self.span.clone();
212            self.handle.spawn(async move {
213                let _span = span.entered();
214                inner.run_jobs();
215            });
216        }
217    }
218}
219
220impl<'scope, 'env: 'scope, R: Send + 'env> Drop for Scope<'scope, 'env, R> {
221    fn drop(&mut self) {
222        // Close the queue by dropping the only sender. This must happen before draining below:
223        // `run_jobs` blocks in `recv` until the queue is closed, so a live sender here would hang
224        // the scope.
225        drop(
226            self.work_queue
227                .take()
228                .expect("sender is taken exactly once, here in Drop"),
229        );
230        // Drain inline so completion never depends on a helper being scheduled.
231        self.inner.run_jobs();
232        self.inner.wait_and_rethrow_panic();
233    }
234}
235
236/// Helper method to spawn tasks in parallel, ensuring that all tasks are awaited and errors are
237/// handled. Also ensures turbo tasks and tracing context are maintained across the tasks.
238///
239/// Jobs are added to a shared work queue and processed by the calling thread plus up to
240/// `runtime worker threads - 1` opportunistic helpers. The helpers are a pure optimization — the
241/// calling thread drains the whole queue by itself if none ever runs — so this does not deadlock on
242/// a thread-limited runtime or when the worker threads are otherwise occupied. Jobs must be
243/// independent (they must not block waiting on each other), since the degree of real concurrency is
244/// bounded by the runtime's worker threads.
245///
246/// Be aware that although this function avoids starving other independently spawned tasks, any
247/// other code running concurrently in the same task will be suspended during the call to
248/// block_in_place. This can happen e.g. when using the `join!` macro. To avoid this issue, call
249/// `scope_bounded` in `spawn_blocking`.
250pub fn scope_bounded<'env, F, R>(number_of_tasks: usize, f: F) -> impl Iterator<Item = R>
251where
252    R: Send + 'env,
253    F: for<'scope> FnOnce(&'scope Scope<'scope, 'env, R>) + 'env,
254{
255    let mut results = Vec::with_capacity(number_of_tasks);
256    for _ in 0..number_of_tasks {
257        results.push(Mutex::new(None));
258    }
259    let results = results.into_boxed_slice();
260    let result = {
261        // SAFETY: We drop the Scope later.
262        let scope = unsafe { Scope::new(&results) };
263        catch_unwind(AssertUnwindSafe(|| f(&scope)))
264    };
265    if let Err(panic) = result {
266        panic::resume_unwind(panic);
267    }
268    results.into_iter().map(|mutex| {
269        mutex
270            .into_inner()
271            .expect("All values are set when the scope returns without panic")
272    })
273}
274
275#[cfg(test)]
276mod tests {
277    use std::{
278        panic::{AssertUnwindSafe, catch_unwind},
279        sync::atomic::AtomicUsize,
280    };
281
282    use super::*;
283
284    /// A scope must make progress even when every runtime worker thread is busy, since the calling
285    /// thread can always drain the shared queue itself.
286    ///
287    /// Every worker thread is pinned by a task blocking synchronously until a release deadline, so
288    /// no helper can be scheduled; we assert the scope still finishes well before that deadline.
289    /// The deadline also guarantees the test fails cleanly instead of hanging.
290    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
291    async fn test_scope_worker_threads_occupied() {
292        const WORKER_THREADS: usize = 2;
293        const JOBS: usize = 64;
294        const RELEASE_AFTER: Duration = Duration::from_secs(4);
295
296        // Pin every runtime worker thread with a task that blocks synchronously (holding its core,
297        // no block_in_place hand-off) until the release deadline.
298        let ready = Arc::new(AtomicUsize::new(0));
299        let mut occupiers = Vec::with_capacity(WORKER_THREADS);
300        for _ in 0..WORKER_THREADS {
301            let ready = ready.clone();
302            occupiers.push(tokio::spawn(async move {
303                ready.fetch_add(1, Ordering::SeqCst);
304                thread::sleep(RELEASE_AFTER);
305            }));
306        }
307        // Wait until both occupiers are actually running (and thus holding both cores).
308        while ready.load(Ordering::SeqCst) < WORKER_THREADS {
309            tokio::task::yield_now().await;
310        }
311
312        let started = Instant::now();
313        let results = tokio::task::spawn_blocking(move || {
314            scope_bounded(JOBS, |scope| {
315                for i in 0..JOBS {
316                    scope.spawn(move || i);
317                }
318            })
319            .collect::<Vec<_>>()
320        })
321        .await
322        .unwrap();
323        let elapsed = started.elapsed();
324
325        assert_eq!(results.len(), JOBS);
326        results.iter().enumerate().for_each(|(i, &result)| {
327            assert_eq!(result, i);
328        });
329        assert!(
330            elapsed < RELEASE_AFTER / 2,
331            "scope_bounded took {elapsed:?}; it should not depend on an occupied worker thread \
332             freeing up"
333        );
334
335        for occupier in occupiers {
336            occupier.await.unwrap();
337        }
338    }
339
340    /// On a `current_thread` runtime no helpers can be spawned and `block_in_place` is not allowed,
341    /// so the calling thread must drain the queue inline rather than panicking or hanging.
342    #[tokio::test(flavor = "current_thread")]
343    async fn test_scope_current_thread_runtime() {
344        let results = tokio::task::spawn_blocking(|| {
345            scope_bounded(16, |scope| {
346                for i in 0..16 {
347                    scope.spawn(move || i);
348                }
349            })
350            .collect::<Vec<_>>()
351        })
352        .await
353        .unwrap();
354        assert_eq!(results.len(), 16);
355        results.iter().enumerate().for_each(|(i, &result)| {
356            assert_eq!(result, i);
357        });
358    }
359
360    /// Helpers must actually add parallelism when threads are available: jobs that each block
361    /// briefly should complete in far less than their serial sum.
362    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
363    async fn test_scope_runs_in_parallel() {
364        const JOBS: usize = 16;
365        const PER_JOB: Duration = Duration::from_millis(50);
366        let started = Instant::now();
367        let results = tokio::task::spawn_blocking(|| {
368            scope_bounded(JOBS, |scope| {
369                for i in 0..JOBS {
370                    scope.spawn(move || {
371                        thread::sleep(PER_JOB);
372                        i
373                    });
374                }
375            })
376            .collect::<Vec<_>>()
377        })
378        .await
379        .unwrap();
380        let elapsed = started.elapsed();
381        assert_eq!(results.len(), JOBS);
382        // Half the serial time is a loose bound on purpose: 4 threads should beat it comfortably,
383        // so a slow machine won't make this flaky.
384        assert!(
385            elapsed < (JOBS as u32 * PER_JOB) / 2,
386            "scope_bounded took {elapsed:?}; expected parallel speedup across worker threads"
387        );
388    }
389
390    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
391    async fn test_scope() {
392        let results = scope_bounded(1000, |scope| {
393            for i in 0..1000 {
394                scope.spawn(move || i);
395            }
396        });
397        let results = results.collect::<Vec<_>>();
398        results.iter().enumerate().for_each(|(i, &result)| {
399            assert_eq!(result, i);
400        });
401        assert_eq!(results.len(), 1000);
402    }
403
404    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
405    async fn test_empty_scope() {
406        let results = scope_bounded(0, |scope| {
407            if false {
408                scope.spawn(|| 42);
409            }
410        });
411        assert_eq!(results.count(), 0);
412    }
413
414    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
415    async fn test_single_task() {
416        let results = scope_bounded(1, |scope| {
417            scope.spawn(|| 42);
418        })
419        .collect::<Vec<_>>();
420        assert_eq!(results, vec![42]);
421    }
422
423    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
424    async fn test_task_finish_before_scope() {
425        let results = scope_bounded(1, |scope| {
426            scope.spawn(|| 42);
427            thread::sleep(std::time::Duration::from_millis(100));
428        })
429        .collect::<Vec<_>>();
430        assert_eq!(results, vec![42]);
431    }
432
433    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
434    async fn test_task_finish_after_scope() {
435        let results = scope_bounded(1, |scope| {
436            scope.spawn(|| {
437                thread::sleep(std::time::Duration::from_millis(100));
438                42
439            });
440        })
441        .collect::<Vec<_>>();
442        assert_eq!(results, vec![42]);
443    }
444
445    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
446    async fn test_panic_in_scope_factory() {
447        let result = catch_unwind(AssertUnwindSafe(|| {
448            let _results = scope_bounded(1000, |scope| {
449                for i in 0..500 {
450                    scope.spawn(move || i);
451                }
452                panic!("Intentional panic");
453            });
454            unreachable!();
455        }));
456        assert!(result.is_err());
457        assert_eq!(
458            result.unwrap_err().downcast_ref::<&str>(),
459            Some(&"Intentional panic")
460        );
461    }
462
463    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
464    async fn test_panic_in_scope_task() {
465        let result = catch_unwind(AssertUnwindSafe(|| {
466            let _results = scope_bounded(1000, |scope| {
467                for i in 0..1000 {
468                    scope.spawn(move || {
469                        if i == 500 {
470                            panic!("Intentional panic");
471                        } else if i == 501 {
472                            panic!("Wrong intentional panic");
473                        } else {
474                            i
475                        }
476                    });
477                }
478            });
479            unreachable!();
480        }));
481        assert!(result.is_err());
482        assert_eq!(
483            result.unwrap_err().downcast_ref::<&str>(),
484            Some(&"Intentional panic")
485        );
486    }
487}