1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
//! SchedulerScope implementation.
use std::{
marker::PhantomData,
mem,
panic::{AssertUnwindSafe, catch_unwind},
ptr::NonNull,
sync::atomic::Ordering,
};
use moirai_core::{
Priority,
error::{ExecutorError, ExecutorResult},
};
use super::super::super::{class::WorkClass, job::ScheduledJob};
use super::super::scope_state::{SchedulerScopeState, ScopedTaskCompletion};
use super::super::types::{SchedulerScope, ThreadScheduler, get_current_worker_id};
use super::super::worker::{execute_job, lock_mutex, next_shared_job};
impl<'scope, C, const BLOCKING_QUEUE_CAPACITY: usize, const SPIN_LIMIT: usize>
SchedulerScope<'scope, C, BLOCKING_QUEUE_CAPACITY, SPIN_LIMIT>
where
C: WorkClass,
{
/// Spawn a job into this scope.
///
/// The job may borrow values that outlive the scope call. Scoped jobs are
/// coalesced into worker-sized scheduler batches and complete before
/// `ThreadScheduler::scope` returns. Jobs are not guaranteed to start while
/// the scope body is still registering work.
///
/// The `usize` the job receives identifies the lane running it. Worker
/// lanes are `0..worker_count()`; a job the admission queue turned away
/// runs on the calling lane, identified as `worker_count()`. It is a lane
/// identity, not an index into the worker set.
pub fn spawn<F>(&self, task: F) -> ExecutorResult<()>
where
F: FnOnce(usize) + Send + 'scope,
{
self.state().register_task();
let completion = ScopedTaskCompletion::new(self.state());
let complete = move |succeeded: bool| completion.finish(succeeded);
// SAFETY: `ThreadScheduler::scope` waits for every scheduled scoped
// job and drops unscheduled buffered jobs before borrowed scope data
// can expire.
let job = unsafe { ScheduledJob::new_scoped_with_completion(task, complete) };
self.jobs.borrow_mut().push(job);
Ok(())
}
/// Schedule all jobs currently buffered in this scope.
///
/// `ThreadScheduler::scope` calls this before waiting, so most callers do
/// not need to invoke it directly. It is exposed for two-lane fork/join
/// shapes where one branch should enter the scheduler before the caller
/// executes the second branch locally. The scope still waits for every
/// flushed job before returning, so borrowed data cannot escape.
pub fn flush(&self) -> ExecutorResult<()> {
let jobs = mem::take(&mut *self.jobs.borrow_mut());
if jobs.is_empty() {
return Ok(());
}
if jobs.len() == 1 {
let job = jobs
.into_iter()
.next()
.expect("single scoped job must exist");
return self.schedule_single(job);
}
let worker_count = self.scheduler.worker_count();
let chunk_count = jobs.len().min(worker_count.max(1));
let chunk_size = jobs.len().div_ceil(chunk_count);
let spread_start = self
.locality_hint
.is_none()
.then(|| self.scheduler.select_worker::<C>(self.priority, None));
let mut pending_jobs = jobs.into_iter();
for chunk_index in 0..chunk_count {
let mut chunk = Vec::with_capacity(chunk_size);
for _ in 0..chunk_size {
if let Some(job) = pending_jobs.next() {
chunk.push(job);
}
}
if chunk.is_empty() {
break;
}
// Select the unhinted batch base once, then distribute physical
// batches across distinct workers. Re-running selection after each
// admission lets a fast first batch change pending/active state and
// route later batches back to its occupied lane, defeating the
// worker-sized coalescing contract and deadlocking saturated joins.
let locality_hint = self.locality_hint.or_else(|| {
spread_start.map(|start| start.wrapping_add(chunk_index) % worker_count)
});
self.schedule_chunk(chunk, locality_hint)?;
}
Ok(())
}
fn schedule_single(&self, job: ScheduledJob) -> ExecutorResult<()> {
self.schedule_job(job, self.locality_hint)
}
fn schedule_job(&self, job: ScheduledJob, locality_hint: Option<usize>) -> ExecutorResult<()> {
let mut job = Some(job);
let admitted = self
.scheduler
.admit_job::<C>(self.priority, locality_hint, &mut job);
self.run_if_refused(admitted, job)
}
fn schedule_chunk(
&self,
jobs: Vec<ScheduledJob>,
locality_hint: Option<usize>,
) -> ExecutorResult<()> {
let scoped_job = move |worker_id| {
for job in jobs {
let _ = job.execute(worker_id);
}
};
// Safety: `ThreadScheduler::scope` waits for every scheduled scoped job
// and drops unscheduled buffered jobs before borrowed scope data can
// expire. A refused job runs below, inside the same scope, so it
// observes the same live borrows.
let job = unsafe { ScheduledJob::new_scoped(scoped_job) };
self.schedule_job(job, locality_hint)
}
/// Run a job the scheduler refused on the calling lane.
///
/// A scope promises its caller that every spawned job runs before the scope
/// returns. Dropping a job the admission queue rejected breaks that promise
/// silently: the caller blocks until the scope joins and then continues as
/// though the work happened. Running it here keeps the promise at the cost
/// of the parallelism that job would have had — the same trade
/// `for_each_indexed` already makes with a rejected chunk, counted by the
/// same `admission_caller_runs` surface.
///
/// Shutdown is not backpressure and is not absorbed: a scheduler that is
/// going away refuses the work, and the error reaches the caller.
fn run_if_refused(
&self,
admitted: ExecutorResult<()>,
refused: Option<ScheduledJob>,
) -> ExecutorResult<()> {
match (admitted, refused) {
(Ok(()), _) => Ok(()),
(Err(ExecutorError::ResourceExhausted(_)), Some(job)) => {
self.scheduler.record_admission_caller_run();
// `execute` contains its own unwind boundary, so a panicking
// job marks its completion token failed exactly as it would on
// a worker instead of unwinding through the scope body.
let _ = job.execute(self.scheduler.caller_lane_id());
Ok(())
}
(Err(error), _) => Err(error),
}
}
fn state(&self) -> &SchedulerScopeState<'scope> {
// Safety: `ThreadScheduler::scope` creates this pointer from a local
// state value and waits for every scheduled scoped job before returning.
unsafe { self.state.as_ref() }
}
}
/// Busy-spin iterations a worker-thread scope waiter performs after exhausting
/// runnable work before it parks on the scope condvar. The waiter only reaches
/// this path when its remaining scoped jobs are actively executing on other
/// workers (nothing left to steal), so a short spin absorbs the common
/// finish-imminently case without an OS park round-trip; the timed park below
/// then bounds idle-CPU while `complete_task` provides the real wakeup.
const SCOPE_HELP_SPIN_LIMIT: usize = 64;
impl<const BLOCKING_QUEUE_CAPACITY: usize, const SPIN_LIMIT: usize>
ThreadScheduler<BLOCKING_QUEUE_CAPACITY, SPIN_LIMIT>
{
/// Run a borrowing job scope on the scheduler and wait for all spawned jobs.
///
/// This is the scheduler-equivalent of a scoped fan-out. It avoids per-task
/// result storage when the caller only needs completion, while preserving the
/// invariant that borrowed data cannot outlive the scope.
///
/// Jobs may borrow data that outlives this call:
///
/// ```
/// use moirai_core::Priority;
/// use moirai_executor::{SyncTask, ThreadScheduler};
///
/// let scheduler = ThreadScheduler::new(2, "scope-doc").unwrap();
/// let total = std::sync::atomic::AtomicU64::new(0);
/// let values = [1_u64, 2, 3];
/// scheduler
/// .scope::<SyncTask, _>(Priority::Normal, None, |scope| {
/// for value in &values {
/// scope.spawn(|_| {
/// total.fetch_add(*value, std::sync::atomic::Ordering::Relaxed);
/// })?;
/// }
/// Ok(())
/// })
/// .unwrap();
/// assert_eq!(total.into_inner(), 6);
/// ```
///
/// A job cannot borrow a value local to the body. The body returns and drops
/// it before the buffered job is scheduled:
///
/// ```compile_fail,E0597
/// use moirai_core::Priority;
/// use moirai_executor::{SyncTask, ThreadScheduler};
///
/// let scheduler = ThreadScheduler::new(2, "scope-doc").unwrap();
/// scheduler
/// .scope::<SyncTask, _>(Priority::Normal, None, |scope| {
/// let local = vec![7_u8; 64];
/// let borrowed: &Vec<u8> = &local;
/// scope.spawn(move |_| assert_eq!(borrowed[0], 7))
/// })
/// .unwrap();
/// ```
pub fn scope<'scope, C, F>(
&'scope self,
priority: Priority,
locality_hint: Option<usize>,
body: F,
) -> ExecutorResult<()>
where
C: WorkClass,
F: FnOnce(
&SchedulerScope<'scope, C, BLOCKING_QUEUE_CAPACITY, SPIN_LIMIT>,
) -> ExecutorResult<()>,
{
if self.inner.shutdown.load(Ordering::Acquire) {
return Err(ExecutorError::ShuttingDown);
}
let state = SchedulerScopeState::new(&self.inner.wait_lock, &self.inner.wait_signal);
let scope = SchedulerScope {
scheduler: self,
state: NonNull::from(&state),
priority,
locality_hint,
jobs: std::cell::RefCell::new(Vec::new()),
_scope: PhantomData,
_class: PhantomData,
};
let body_result = catch_unwind(AssertUnwindSafe(|| body(&scope)));
// `flush` may enqueue lifetime-erased borrowing jobs before an internal
// unwind. Catch it so the scope state remains live through the drain.
let flush_result = catch_unwind(AssertUnwindSafe(|| scope.flush()));
// A panic here would unwind `scope` while buffered or running jobs still
// hold `state` and the caller's borrows, so it cannot be allowed to
// escape: the process aborts, as `std::thread::scope` does.
if catch_unwind(AssertUnwindSafe(|| self.drain_scope(&state))).is_err() {
std::process::abort();
}
match body_result {
Err(payload) => std::panic::resume_unwind(payload),
Ok(body_result) => match flush_result {
Err(payload) => std::panic::resume_unwind(payload),
Ok(flush_result) => match body_result {
Ok(()) if state.has_panicked() => Err(ExecutorError::SpawnFailed(
moirai_core::error::TaskError::Panicked,
)),
Ok(()) => flush_result.and_then(|()| state.unrun_job_result()),
Err(error) => Err(error),
},
},
}
}
/// Wait for every job registered on `state` to complete.
///
/// If the caller is itself a scheduler worker, it participates in work
/// stealing instead of parking: a worker that blocks inside `scope` while
/// its nested scoped jobs sit unrun would otherwise remove itself from the
/// pool and deadlock the fork-join (provably so on a single-worker pool, and
/// a source of use-after-free on the scope's stack-owned state under
/// concurrent nesting). Running its own queue via `next_job` keeps the pool
/// making progress, so nesting is deadlock-free and the scope state stays
/// live until every borrowing job has completed. `next_job(worker_id)` only
/// pops this worker's own deque and steals into it, so the aliasing rules of
/// the single-owner Chase–Lev deques are preserved.
///
/// A non-worker caller parks (`SchedulerScopeState::wait`): the worker pool
/// drains its scoped jobs, so it never starves anything by blocking. A
/// caller that helped from its own lane crashed consumers (moirai-iter's
/// nested iteration on CI, kwavers' 3-D FFT at 32³ and above); until the
/// race is found the join waits as before.
pub(super) fn drain_scope(&self, state: &SchedulerScopeState<'_>) {
let Some(worker_id) = self.own_worker_id() else {
state.wait();
return;
};
let inner = &self.inner;
let mut idle_spins = 0usize;
loop {
if state.pending_tasks.load(Ordering::Acquire) == 0 {
state.wait();
return;
}
if let Some(job) = next_shared_job(inner, worker_id) {
execute_job(inner, worker_id, job);
idle_spins = 0;
continue;
}
// Scope still pending but nothing runnable: the remaining scoped jobs
// are executing on other workers. Spin briefly, then park on the
// scope condvar with a timeout so `complete_task` wakes us while we
// still periodically re-probe for freshly stealable work.
if idle_spins < SCOPE_HELP_SPIN_LIMIT {
idle_spins += 1;
core::hint::spin_loop();
continue;
}
idle_spins = 0;
let guard = lock_mutex(state.wait_lock);
if state.pending_tasks.load(Ordering::Acquire) != 0 {
let _ = state
.wait_signal
.wait_timeout(guard, std::time::Duration::from_micros(50))
.unwrap_or_else(|poisoned| poisoned.into_inner());
}
}
}
/// Index of the calling thread among this scheduler's own workers.
///
/// The worker-id thread cache is process-wide, so it names whichever
/// scheduler owns the thread. Indexing this scheduler's worker table with
/// a foreign id panics or drains the wrong deques, so membership is
/// confirmed against the thread each worker registers when it starts.
fn own_worker_id(&self) -> Option<usize> {
let id = get_current_worker_id()?;
let registered = self.inner.workers.get(id)?.thread.get()?;
(registered.id() == std::thread::current().id()).then_some(id)
}
}