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    /// The caller's turbo-tasks context, entered by each helper task so jobs on helper threads see
123    /// the same context as jobs drained inline by the calling thread.
124    turbo_tasks: Option<Arc<dyn TurboTasksApi>>,
125    span: Span,
126    /// Invariance over 'env, to make sure 'env cannot shrink, which is necessary for soundness.
127    ///
128    /// See the comment in the stdlib implementation:
129    /// <https://github.com/rust-lang/rust/blob/3b1b0ef4d8/library/std/src/thread/scoped.rs#L12-L33>
130    env: PhantomData<&'env mut &'env ()>,
131}
132
133impl<'scope, 'env: 'scope, R: Send + 'env> Scope<'scope, 'env, R> {
134    /// Creates a new scope.
135    ///
136    /// # Safety
137    ///
138    /// The caller must ensure `Scope` is dropped and not forgotten.
139    unsafe fn new(results: &'scope [Mutex<Option<R>>]) -> Self {
140        let handle = Handle::current();
141        // Never use more threads than there are jobs, or than the runtime has workers.
142        let worker_tasks = NonZeroUsize::new(handle.metrics().num_workers().min(results.len()))
143            .unwrap_or(NonZeroUsize::MIN);
144        let (sender, receiver) = mpmc::channel();
145        Self {
146            results,
147            index: AtomicUsize::new(0),
148            inner: Arc::new(ScopeInner {
149                main_thread: thread::current(),
150                remaining_tasks: AtomicUsize::new(0),
151                panic: Mutex::new(None),
152                work_queue: receiver,
153            }),
154            work_queue: Some(sender),
155            handle,
156            worker_tasks,
157            turbo_tasks: try_turbo_tasks(),
158            span: Span::current(),
159            env: PhantomData,
160        }
161    }
162
163    /// Spawns a new task in the scope.
164    pub fn spawn<F>(&self, f: F)
165    where
166        F: FnOnce() -> R + Send + 'env,
167    {
168        let index = self.index.fetch_add(1, Ordering::Relaxed);
169        assert!(index < self.results.len(), "Too many tasks spawned");
170        let result_cell: &Mutex<Option<R>> = &self.results[index];
171
172        // The turbo-tasks context is not entered per job: helpers enter it once (below), and the
173        // calling thread already has it. Re-entering it on the calling thread would panic when the
174        // caller holds a borrow of the task-local, e.g. inside `with_turbo_tasks`.
175        let f: Box<dyn FnOnce() + Send + 'scope> = Box::new(|| {
176            let result = f();
177            *result_cell.lock() = Some(result);
178        });
179        let f: *mut (dyn FnOnce() + Send + 'scope) = Box::into_raw(f);
180
181        // SAFETY: Scope ensures (e. g. in Drop) that spawned tasks is awaited before the
182        // lifetime `'env` ends.
183        let f = unsafe {
184            std::mem::transmute::<
185                *mut (dyn FnOnce() + Send + 'scope),
186                *mut (dyn FnOnce() + Send + 'static),
187            >(f)
188        };
189
190        // SAFETY: We just called `Box::into_raw`.
191        let f = unsafe { Box::from_raw(f) };
192
193        self.inner.remaining_tasks.fetch_add(1, Ordering::Relaxed);
194
195        // Add to the shared work queue, all threads read from this. Neither failure is reachable,
196        // but a job silently dropped here would leave `remaining_tasks` above zero and hang the
197        // scope, so panic instead.
198        self.work_queue
199            .as_ref()
200            .expect("sender is only taken in Drop")
201            .send((index, f))
202            .expect("receiver is owned by inner and outlives the scope");
203
204        // Spawn a tokio worker for each job until we hit the max `worker_tasks`.
205        if index < self.worker_tasks.get() - 1 {
206            let inner = self.inner.clone();
207            let span = self.span.clone();
208            let turbo_tasks = self.turbo_tasks.clone();
209            self.handle.spawn(async move {
210                let _span = span.entered();
211                if let Some(turbo_tasks) = turbo_tasks {
212                    // Ensure that the turbo tasks context is maintained across the jobs.
213                    turbo_tasks_scope(turbo_tasks, || inner.run_jobs());
214                } else {
215                    inner.run_jobs();
216                }
217            });
218        }
219    }
220}
221
222impl<'scope, 'env: 'scope, R: Send + 'env> Drop for Scope<'scope, 'env, R> {
223    fn drop(&mut self) {
224        // Close the queue by dropping the only sender. This must happen before draining below:
225        // `run_jobs` blocks in `recv` until the queue is closed, so a live sender here would hang
226        // the scope.
227        drop(
228            self.work_queue
229                .take()
230                .expect("sender is taken exactly once, here in Drop"),
231        );
232        // Drain inline so completion never depends on a helper being scheduled.
233        self.inner.run_jobs();
234        self.inner.wait_and_rethrow_panic();
235    }
236}
237
238/// Helper method to spawn tasks in parallel, ensuring that all tasks are awaited and errors are
239/// handled. Also ensures turbo tasks and tracing context are maintained across the tasks.
240///
241/// Jobs are added to a shared work queue and processed by the calling thread plus up to
242/// `runtime worker threads - 1` opportunistic helpers. The helpers are a pure optimization — the
243/// calling thread drains the whole queue by itself if none ever runs — so this does not deadlock on
244/// a thread-limited runtime or when the worker threads are otherwise occupied. Jobs must be
245/// independent (they must not block waiting on each other), since the degree of real concurrency is
246/// bounded by the runtime's worker threads.
247///
248/// Be aware that although this function avoids starving other independently spawned tasks, any
249/// other code running concurrently in the same task will be suspended during the call to
250/// block_in_place. This can happen e.g. when using the `join!` macro. To avoid this issue, call
251/// `scope_bounded` in `spawn_blocking`.
252pub fn scope_bounded<'env, F, R>(number_of_tasks: usize, f: F) -> impl Iterator<Item = R>
253where
254    R: Send + 'env,
255    F: for<'scope> FnOnce(&'scope Scope<'scope, 'env, R>) + 'env,
256{
257    let mut results = Vec::with_capacity(number_of_tasks);
258    for _ in 0..number_of_tasks {
259        results.push(Mutex::new(None));
260    }
261    let results = results.into_boxed_slice();
262    let result = {
263        // SAFETY: We drop the Scope later.
264        let scope = unsafe { Scope::new(&results) };
265        catch_unwind(AssertUnwindSafe(|| f(&scope)))
266    };
267    if let Err(panic) = result {
268        panic::resume_unwind(panic);
269    }
270    results.into_iter().map(|mutex| {
271        mutex
272            .into_inner()
273            .expect("All values are set when the scope returns without panic")
274    })
275}
276
277#[cfg(test)]
278mod tests {
279    use std::{
280        panic::{AssertUnwindSafe, catch_unwind},
281        sync::atomic::AtomicUsize,
282    };
283
284    use super::*;
285
286    /// A scope must make progress even when every runtime worker thread is busy, since the calling
287    /// thread can always drain the shared queue itself.
288    ///
289    /// Every worker thread is pinned by a task blocking synchronously until a release deadline, so
290    /// no helper can be scheduled; we assert the scope still finishes well before that deadline.
291    /// The deadline also guarantees the test fails cleanly instead of hanging.
292    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
293    async fn test_scope_worker_threads_occupied() {
294        const WORKER_THREADS: usize = 2;
295        const JOBS: usize = 64;
296        const RELEASE_AFTER: Duration = Duration::from_secs(4);
297
298        // Pin every runtime worker thread with a task that blocks synchronously (holding its core,
299        // no block_in_place hand-off) until the release deadline.
300        let ready = Arc::new(AtomicUsize::new(0));
301        let mut occupiers = Vec::with_capacity(WORKER_THREADS);
302        for _ in 0..WORKER_THREADS {
303            let ready = ready.clone();
304            occupiers.push(tokio::spawn(async move {
305                ready.fetch_add(1, Ordering::SeqCst);
306                thread::sleep(RELEASE_AFTER);
307            }));
308        }
309        // Wait until both occupiers are actually running (and thus holding both cores).
310        while ready.load(Ordering::SeqCst) < WORKER_THREADS {
311            tokio::task::yield_now().await;
312        }
313
314        let started = Instant::now();
315        let results = tokio::task::spawn_blocking(move || {
316            scope_bounded(JOBS, |scope| {
317                for i in 0..JOBS {
318                    scope.spawn(move || i);
319                }
320            })
321            .collect::<Vec<_>>()
322        })
323        .await
324        .unwrap();
325        let elapsed = started.elapsed();
326
327        assert_eq!(results.len(), JOBS);
328        results.iter().enumerate().for_each(|(i, &result)| {
329            assert_eq!(result, i);
330        });
331        assert!(
332            elapsed < RELEASE_AFTER / 2,
333            "scope_bounded took {elapsed:?}; it should not depend on an occupied worker thread \
334             freeing up"
335        );
336
337        for occupier in occupiers {
338            occupier.await.unwrap();
339        }
340    }
341
342    /// On a `current_thread` runtime no helpers can be spawned and `block_in_place` is not allowed,
343    /// so the calling thread must drain the queue inline rather than panicking or hanging.
344    #[tokio::test(flavor = "current_thread")]
345    async fn test_scope_current_thread_runtime() {
346        let results = tokio::task::spawn_blocking(|| {
347            scope_bounded(16, |scope| {
348                for i in 0..16 {
349                    scope.spawn(move || i);
350                }
351            })
352            .collect::<Vec<_>>()
353        })
354        .await
355        .unwrap();
356        assert_eq!(results.len(), 16);
357        results.iter().enumerate().for_each(|(i, &result)| {
358            assert_eq!(result, i);
359        });
360    }
361
362    /// Helpers must actually add parallelism when threads are available: jobs that each block
363    /// briefly should complete in far less than their serial sum.
364    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
365    // Node Worker startup makes the native timing bound too strict on wasm. A later layer uses a
366    // platform-aware bound that still distinguishes parallel from serial execution.
367    #[cfg_attr(
368        target_family = "wasm",
369        ignore = "timing bound is too strict for Node Worker startup"
370    )]
371    async fn test_scope_runs_in_parallel() {
372        const JOBS: usize = 16;
373        const PER_JOB: Duration = Duration::from_millis(50);
374        let started = Instant::now();
375        let results = tokio::task::spawn_blocking(|| {
376            scope_bounded(JOBS, |scope| {
377                for i in 0..JOBS {
378                    scope.spawn(move || {
379                        thread::sleep(PER_JOB);
380                        i
381                    });
382                }
383            })
384            .collect::<Vec<_>>()
385        })
386        .await
387        .unwrap();
388        let elapsed = started.elapsed();
389        assert_eq!(results.len(), JOBS);
390        // Half the serial time is a loose bound on purpose: 4 threads should beat it comfortably,
391        // so a slow machine won't make this flaky.
392        assert!(
393            elapsed < (JOBS as u32 * PER_JOB) / 2,
394            "scope_bounded took {elapsed:?}; expected parallel speedup across worker threads"
395        );
396    }
397
398    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
399    async fn test_scope() {
400        let results = scope_bounded(1000, |scope| {
401            for i in 0..1000 {
402                scope.spawn(move || i);
403            }
404        });
405        let results = results.collect::<Vec<_>>();
406        results.iter().enumerate().for_each(|(i, &result)| {
407            assert_eq!(result, i);
408        });
409        assert_eq!(results.len(), 1000);
410    }
411
412    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
413    async fn test_empty_scope() {
414        let results = scope_bounded(0, |scope| {
415            if false {
416                scope.spawn(|| 42);
417            }
418        });
419        assert_eq!(results.count(), 0);
420    }
421
422    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
423    async fn test_single_task() {
424        let results = scope_bounded(1, |scope| {
425            scope.spawn(|| 42);
426        })
427        .collect::<Vec<_>>();
428        assert_eq!(results, vec![42]);
429    }
430
431    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
432    async fn test_task_finish_before_scope() {
433        let results = scope_bounded(1, |scope| {
434            scope.spawn(|| 42);
435            thread::sleep(std::time::Duration::from_millis(100));
436        })
437        .collect::<Vec<_>>();
438        assert_eq!(results, vec![42]);
439    }
440
441    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
442    async fn test_task_finish_after_scope() {
443        let results = scope_bounded(1, |scope| {
444            scope.spawn(|| {
445                thread::sleep(std::time::Duration::from_millis(100));
446                42
447            });
448        })
449        .collect::<Vec<_>>();
450        assert_eq!(results, vec![42]);
451    }
452
453    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
454    // Relies on `catch_unwind` catching, which needs unwinding; wasm is panic = abort.
455    #[cfg_attr(
456        target_family = "wasm",
457        ignore = "no unwinding on wasm: std is built panic=abort, so catch_unwind cannot catch"
458    )]
459    async fn test_panic_in_scope_factory() {
460        let result = catch_unwind(AssertUnwindSafe(|| {
461            let _results = scope_bounded(1000, |scope| {
462                for i in 0..500 {
463                    scope.spawn(move || i);
464                }
465                panic!("Intentional panic");
466            });
467            unreachable!();
468        }));
469        assert!(result.is_err());
470        assert_eq!(
471            result.unwrap_err().downcast_ref::<&str>(),
472            Some(&"Intentional panic")
473        );
474    }
475
476    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
477    // Relies on `catch_unwind` catching, which needs unwinding; wasm is panic = abort.
478    #[cfg_attr(
479        target_family = "wasm",
480        ignore = "no unwinding on wasm: std is built panic=abort, so catch_unwind cannot catch"
481    )]
482    async fn test_panic_in_scope_task() {
483        let result = catch_unwind(AssertUnwindSafe(|| {
484            let _results = scope_bounded(1000, |scope| {
485                for i in 0..1000 {
486                    scope.spawn(move || {
487                        if i == 500 {
488                            panic!("Intentional panic");
489                        } else if i == 501 {
490                            panic!("Wrong intentional panic");
491                        } else {
492                            i
493                        }
494                    });
495                }
496            });
497            unreachable!();
498        }));
499        assert!(result.is_err());
500        assert_eq!(
501            result.unwrap_err().downcast_ref::<&str>(),
502            Some(&"Intentional panic")
503        );
504    }
505}