Skip to main content

moirai_executor/schedule/runtime/
worker.rs

1//! Worker loop and associated free functions for the thread scheduler runtime.
2
3use std::{
4    sync::{Mutex, MutexGuard},
5    thread,
6};
7
8use moirai_core::error::{ExecutorError, ExecutorResult};
9
10use super::super::job::ScheduledJob;
11use super::super::queue::WorkerQueueOwner;
12
13use super::types::{set_current_worker_id, ContendedWakePolicy, SchedulerInner, WorkerState};
14
15#[cfg(feature = "scheduler-diagnostics")]
16use super::types::BoundedContendedWake;
17
18pub(super) const WORKER_IDLE_SPIN_ATTEMPTS: usize = 256;
19pub(super) const JOIN_FAST_SPIN_ATTEMPTS: usize = WORKER_IDLE_SPIN_ATTEMPTS;
20
21pub(super) fn worker_loop<const QUEUE_CAPACITY: usize, const SPIN_LIMIT: usize>(
22    inner: std::sync::Arc<SchedulerInner<QUEUE_CAPACITY>>,
23    worker_id: usize,
24    mut owner: WorkerQueueOwner<QUEUE_CAPACITY>,
25) {
26    set_current_worker_id(Some(worker_id));
27    let _ = inner.workers[worker_id].thread.set(thread::current());
28
29    loop {
30        if let Some(job) = next_job(&inner, worker_id, &mut owner) {
31            execute_job(&inner, worker_id, job);
32            continue;
33        }
34
35        if should_stop(&inner) {
36            break;
37        }
38
39        if spin_for_work::<QUEUE_CAPACITY, SPIN_LIMIT>(&inner, worker_id) {
40            continue;
41        }
42
43        // Run defragmentation sweeps only right before blocking in wait_for_work
44        // to avoid latency overheads during active work stealing and spinning.
45        run_idle_memory_maintenance();
46
47        wait_for_work(&inner, worker_id);
48    }
49}
50
51#[cfg(feature = "mnemosyne")]
52melinoe::thread_cached! {
53    mod last_maintenance_time: std::time::Instant;
54}
55
56#[inline]
57fn run_idle_memory_maintenance() {
58    #[cfg(feature = "mnemosyne")]
59    {
60        use mnemosyne::{LocalAllocatorSelector, MemoryBackendWrapper, StandardPolicy};
61        if <MemoryBackendWrapper as LocalAllocatorSelector<MemoryBackendWrapper>>::get_allocator_ptr_raw().is_null() {
62            return;
63        }
64
65        let now = std::time::Instant::now();
66        let should_run = if let Some(last) = last_maintenance_time::get() {
67            if now.duration_since(last) >= std::time::Duration::from_millis(500) {
68                last_maintenance_time::set(now);
69                true
70            } else {
71                false
72            }
73        } else {
74            last_maintenance_time::set(now);
75            true
76        };
77
78        if should_run {
79            let _ =
80                <MemoryBackendWrapper as LocalAllocatorSelector<MemoryBackendWrapper>>::with_allocator(
81                    |alloc| unsafe {
82                        alloc.periodic_defragmentation_sweep::<StandardPolicy>();
83                    },
84                );
85        }
86    }
87}
88
89pub(super) fn next_job<const QUEUE_CAPACITY: usize>(
90    inner: &SchedulerInner<QUEUE_CAPACITY>,
91    worker_id: usize,
92    owner: &mut WorkerQueueOwner<QUEUE_CAPACITY>,
93) -> Option<ScheduledJob> {
94    let local = &inner.workers[worker_id];
95    local
96        .lifo_slot
97        .pop()
98        .or_else(|| owner.pop_local())
99        .or_else(|| steal_job(inner, worker_id, owner))
100}
101
102/// Obtain runnable work using only shared top-side capabilities.
103pub(super) fn next_shared_job<const QUEUE_CAPACITY: usize>(
104    inner: &SchedulerInner<QUEUE_CAPACITY>,
105    worker_id: usize,
106) -> Option<ScheduledJob> {
107    let local = &inner.workers[worker_id];
108    local
109        .lifo_slot
110        .pop()
111        .or_else(|| local.queues.steal_one())
112        .or_else(|| steal_shared_job(inner, worker_id))
113}
114
115fn steal_job<const QUEUE_CAPACITY: usize>(
116    inner: &SchedulerInner<QUEUE_CAPACITY>,
117    worker_id: usize,
118    owner: &mut WorkerQueueOwner<QUEUE_CAPACITY>,
119) -> Option<ScheduledJob> {
120    let worker_count = inner.workers.len();
121    let my_node = inner.worker_numa_nodes.get(worker_id).copied().flatten();
122
123    // Two-pass NUMA-aware victim selection:
124    //
125    // Pass 1 (same-NUMA-node): prefer victims on the same NUMA node as the
126    // thief.  Same-node steals access memory already in the local NUMA bank,
127    // avoiding cross-socket NUMA traffic on multi-socket systems.  Skipped
128    // when topology is unavailable (my_node == None) or only one node exists.
129    //
130    // Pass 2 (all workers): fall back to the full-ring randomised scan so
131    // coverage and worst-case load balance are preserved — same as the
132    // previous implementation.
133    //
134    // Both passes use xorshift64 randomisation (Blumofe–Leiserson) to spread
135    // the first steal attempt and prevent thundering-herd CAS contention.
136    if let Some(node) = my_node {
137        let start = next_steal_start();
138        for offset in 0..worker_count {
139            let victim_index = (start.wrapping_add(offset)) % worker_count;
140            if victim_index == worker_id {
141                continue;
142            }
143            // Only try same-node victims in pass 1.
144            if inner.worker_numa_nodes.get(victim_index).copied().flatten() != Some(node) {
145                continue;
146            }
147            let victim = &inner.workers[victim_index];
148            if let Some(job) = owner.steal_batch(&victim.queues) {
149                return Some(job);
150            }
151            if let Some(job) = victim.lifo_slot.steal() {
152                return Some(job);
153            }
154        }
155    }
156
157    // Pass 2: full ring scan from a fresh random origin, skipping self.
158    let start = next_steal_start();
159    for offset in 0..worker_count {
160        let victim_index = (start.wrapping_add(offset)) % worker_count;
161        if victim_index == worker_id {
162            continue;
163        }
164        let victim = &inner.workers[victim_index];
165        if let Some(job) = owner.steal_batch(&victim.queues) {
166            return Some(job);
167        }
168        if let Some(job) = victim.lifo_slot.steal() {
169            return Some(job);
170        }
171    }
172
173    None
174}
175
176fn steal_shared_job<const QUEUE_CAPACITY: usize>(
177    inner: &SchedulerInner<QUEUE_CAPACITY>,
178    worker_id: usize,
179) -> Option<ScheduledJob> {
180    let worker_count = inner.workers.len();
181    let start = next_steal_start();
182    for offset in 0..worker_count {
183        let victim_index = start.wrapping_add(offset) % worker_count;
184        if victim_index == worker_id {
185            continue;
186        }
187        let victim = &inner.workers[victim_index];
188        if let Some(job) = victim.queues.steal_one() {
189            return Some(job);
190        }
191        if let Some(job) = victim.lifo_slot.steal() {
192            return Some(job);
193        }
194    }
195    None
196}
197
198/// Thread-local xorshift64 producing a randomized starting victim index.
199///
200/// Seeded lazily from the per-thread RNG cell's own address (stable and unique
201/// per worker thread, forced non-zero), so it needs no shared atomic on the hot
202/// path -- the seed source is contention-free by construction.
203fn next_steal_start() -> usize {
204    use std::cell::Cell;
205    thread_local!(static RNG: Cell<u64> = const { Cell::new(0) });
206    RNG.with(|cell| {
207        let mut x = cell.get();
208        if x == 0 {
209            x = (cell as *const Cell<u64> as u64) | 1;
210        }
211        x ^= x << 13;
212        x ^= x >> 7;
213        x ^= x << 17;
214        cell.set(x);
215        x as usize
216    })
217}
218
219pub(super) fn execute_job<const QUEUE_CAPACITY: usize>(
220    inner: &SchedulerInner<QUEUE_CAPACITY>,
221    worker_id: usize,
222    job: ScheduledJob,
223) {
224    execute_job_with_counters(
225        inner,
226        worker_id,
227        job,
228        &inner.pending_tasks,
229        &inner.active_workers,
230    );
231}
232
233pub(super) fn execute_blocking_job<const QUEUE_CAPACITY: usize>(
234    inner: &SchedulerInner<QUEUE_CAPACITY>,
235    worker_id: usize,
236    job: ScheduledJob,
237) {
238    execute_job_with_counters(
239        inner,
240        worker_id,
241        job,
242        &inner.blocking_pending_tasks,
243        &inner.blocking_active_workers,
244    );
245}
246
247fn execute_job_with_counters<const QUEUE_CAPACITY: usize>(
248    inner: &SchedulerInner<QUEUE_CAPACITY>,
249    worker_id: usize,
250    job: ScheduledJob,
251    pending_tasks: &std::sync::atomic::AtomicUsize,
252    active_workers: &std::sync::atomic::AtomicUsize,
253) {
254    use std::sync::atomic::Ordering;
255    active_workers.fetch_add(1, Ordering::Release);
256    pending_tasks.fetch_sub(1, Ordering::Release);
257
258    if job.execute(worker_id) {
259        inner.completed_tasks.fetch_add(1, Ordering::Relaxed);
260    } else {
261        inner.failed_tasks.fetch_add(1, Ordering::Relaxed);
262    }
263
264    // SeqCst (not AcqRel): this decrement-to-zero publishes quiescence to a
265    // parking `join()` waiter and is one half of a store-buffer (Dekker)
266    // handshake — the worker stores `active -> 0` here then `notify_quiescent`
267    // loads `join_waiters`, while `join` stores `join_waiters += 1` then
268    // `is_quiescent` loads `active`. All four accesses must share one SeqCst
269    // total order; with AcqRel the StoreLoad reordering admits the lost-wakeup
270    // outcome (joiner reads stale `active != 0` and parks while the worker reads
271    // stale `join_waiters == 0` and never signals — a hung `join()`), proven
272    // reachable by `tests/loom_join_quiescence.rs`. On x86 `lock sub`/`lock xadd`
273    // is already a full barrier, so this is free.
274    if active_workers.fetch_sub(1, Ordering::SeqCst) == 1 {
275        notify_quiescent(inner);
276    }
277}
278
279fn should_stop<const QUEUE_CAPACITY: usize>(inner: &SchedulerInner<QUEUE_CAPACITY>) -> bool {
280    use std::sync::atomic::Ordering;
281    inner.shutdown.load(Ordering::Acquire) && inner.pending_tasks.load(Ordering::Acquire) == 0
282}
283
284fn spin_for_work<const QUEUE_CAPACITY: usize, const SPIN_LIMIT: usize>(
285    inner: &SchedulerInner<QUEUE_CAPACITY>,
286    worker_id: usize,
287) -> bool {
288    use std::sync::atomic::Ordering;
289    for attempt in 0..SPIN_LIMIT {
290        core::hint::spin_loop();
291        let local = &inner.workers[worker_id];
292        if !local.queues.is_empty()
293            || local.lifo_slot.state.load(Ordering::Relaxed) == 2
294            || should_stop(inner)
295        {
296            return true;
297        }
298
299        // Periodically check if other workers have stealable tasks to avoid parking
300        if attempt % 32 == 0 && (has_stealable_work(inner, worker_id) || should_stop(inner)) {
301            return true;
302        }
303    }
304
305    false
306}
307
308fn has_stealable_work<const QUEUE_CAPACITY: usize>(
309    inner: &SchedulerInner<QUEUE_CAPACITY>,
310    worker_id: usize,
311) -> bool {
312    use std::sync::atomic::Ordering;
313    let worker_count = inner.workers.len();
314    // Bounded randomized probe: instead of scanning all `worker_count - 1`
315    // victims every 32 spins (O(N) per check, cache-line churn on large pools),
316    // probe at most `STEAL_PROBE_LIMIT` victims starting from a random offset.
317    // Missing stealable work is harmless - the worker continues spinning and
318    // `steal_job` (called from `next_job`) still scans the full ring. The probe
319    // only needs to find *some* victim with work to break out of the spin early.
320    const STEAL_PROBE_LIMIT: usize = 8;
321    let start = next_steal_start();
322    let probe_count = worker_count.min(STEAL_PROBE_LIMIT);
323    for offset in 0..probe_count {
324        let victim_index = (start.wrapping_add(offset)) % worker_count;
325        if victim_index == worker_id {
326            continue;
327        }
328        let victim = &inner.workers[victim_index];
329        if !victim.queues.is_empty() || victim.lifo_slot.state.load(Ordering::Relaxed) == 2 {
330            return true;
331        }
332    }
333    false
334}
335
336fn wait_for_work<const QUEUE_CAPACITY: usize>(
337    inner: &SchedulerInner<QUEUE_CAPACITY>,
338    worker_id: usize,
339) {
340    use std::sync::atomic::Ordering;
341    // Register this worker as parked in the wake bitset, then re-check
342    // `pending_tasks` under the same SeqCst order. The `set` and the load form
343    // the worker half of the store-buffer handshake with `schedule_job`'s SeqCst
344    // increment + bitset scan, which is what rules out a lost wakeup. Every
345    // worker registers (no id < 64 special case), so large pools have no
346    // unreachable workers.
347    inner.idle_workers.set(worker_id);
348    while inner.pending_tasks.load(Ordering::SeqCst) == 0 && !inner.shutdown.load(Ordering::SeqCst)
349    {
350        // Park until `schedule_job` unparks us. Async I/O readiness is driven by
351        // moirai_pal's dedicated global reactor thread, whose wakers reschedule
352        // their tasks through `schedule_job` -- so a parked worker is woken the
353        // same way for an async completion as for fresh sync work, and never
354        // needs to drive the reactor itself.
355        //
356        // Workers previously ran a 1 ms `reactor.run_iteration` here. That poll
357        // is not interruptible by `unpark` and rounds up to the OS timer
358        // granularity (~15 ms on Windows), so scheduling sync work to an idle
359        // pool stalled until the poll returned -- a latency the large `SPIN_LIMIT`
360        // was masking. Parking restores microsecond wake latency.
361        thread::park();
362    }
363    inner.idle_workers.clear(worker_id);
364}
365
366pub(super) fn wake_worker<const QUEUE_CAPACITY: usize>(worker: &WorkerState<QUEUE_CAPACITY>) {
367    if let Some(thread) = worker.thread.get() {
368        thread.unpark();
369    }
370}
371
372pub(super) fn wake_all_workers<const QUEUE_CAPACITY: usize>(
373    inner: &SchedulerInner<QUEUE_CAPACITY>,
374) {
375    for worker in inner.workers.iter() {
376        wake_worker(worker);
377    }
378}
379
380pub(super) trait ContendedWakable {
381    /// Worker-pool size; consumed only by the diagnostics wake-decision path.
382    #[cfg(feature = "scheduler-diagnostics")]
383    fn worker_count(&self) -> usize;
384    /// Direct single-worker wake; consumed only by the diagnostics wake-decision path.
385    #[cfg(feature = "scheduler-diagnostics")]
386    fn wake_worker(&self, worker_index: usize);
387    fn wake_contended<P>(&self, worker_index: usize, previous_pending: usize) -> usize
388    where
389        P: ContendedWakePolicy;
390}
391
392impl<const QUEUE_CAPACITY: usize> ContendedWakable for SchedulerInner<QUEUE_CAPACITY> {
393    #[cfg(feature = "scheduler-diagnostics")]
394    fn worker_count(&self) -> usize {
395        self.workers.len()
396    }
397
398    #[cfg(feature = "scheduler-diagnostics")]
399    fn wake_worker(&self, worker_index: usize) {
400        wake_worker(&self.workers[worker_index]);
401    }
402
403    fn wake_contended<P>(&self, worker_index: usize, previous_pending: usize) -> usize
404    where
405        P: ContendedWakePolicy,
406    {
407        let worker_count = self.workers.len();
408        wake_worker(&self.workers[worker_index]);
409
410        if P::WAKE_LIMIT < 2 || worker_count < 2 {
411            return 1;
412        }
413
414        let peer_index = worker_index.wrapping_add(previous_pending) % worker_count;
415        wake_worker(&self.workers[peer_index]);
416        2
417    }
418}
419
420#[cold]
421#[inline(never)]
422pub(super) fn wake_contended_workers<P>(
423    inner: &impl ContendedWakable,
424    worker_index: usize,
425    previous_pending: usize,
426) -> usize
427where
428    P: ContendedWakePolicy,
429{
430    inner.wake_contended::<P>(worker_index, previous_pending)
431}
432
433#[cfg(feature = "scheduler-diagnostics")]
434#[inline]
435pub(super) fn diagnostic_publish_work_available(
436    inner: &impl ContendedWakable,
437    worker_index: usize,
438    previous_pending: usize,
439) -> usize {
440    let worker_count = inner.worker_count();
441    if previous_pending == 0 {
442        inner.wake_worker(worker_index);
443        1
444    } else if previous_pending < worker_count {
445        wake_contended_workers::<BoundedContendedWake>(inner, worker_index, previous_pending)
446    } else {
447        0
448    }
449}
450
451pub(super) fn notify_quiescent<const QUEUE_CAPACITY: usize>(
452    inner: &SchedulerInner<QUEUE_CAPACITY>,
453) {
454    use std::sync::atomic::Ordering;
455    // SeqCst: the worker half of the quiescence Dekker handshake (see the
456    // `active_workers` decrement in `execute_job`). This load must be in the same
457    // SeqCst total order as `join`'s `join_waiters` increment, or a just-arrived
458    // waiter is missed.
459    if inner.join_waiters.load(Ordering::SeqCst) != 0 && is_quiescent(inner) {
460        let _guard = lock_mutex(&inner.wait_lock);
461        if is_quiescent(inner) {
462            inner.wait_signal.notify_all();
463        }
464    }
465}
466
467pub(super) fn is_quiescent<const QUEUE_CAPACITY: usize>(
468    inner: &SchedulerInner<QUEUE_CAPACITY>,
469) -> bool {
470    use std::sync::atomic::Ordering;
471    // SeqCst: the `active_workers` load is the joiner's half of the quiescence
472    // Dekker handshake (see `execute_job`) and must sit in the shared SeqCst
473    // total order; `pending_tasks` is loaded SeqCst too so the full quiescence
474    // predicate is evaluated against one consistent order. SeqCst loads are a
475    // plain load on x86 (`mov`), so this is cheap on the common target.
476    inner.pending_tasks.load(Ordering::SeqCst) == 0
477        && inner.active_workers.load(Ordering::SeqCst) == 0
478        && inner.blocking_pending_tasks.load(Ordering::SeqCst) == 0
479        && inner.blocking_active_workers.load(Ordering::SeqCst) == 0
480}
481
482pub(super) fn inline_map_reduce<T, Map, Reduce>(
483    count: usize,
484    identity: T,
485    map: Map,
486    reduce: Reduce,
487) -> ExecutorResult<T>
488where
489    Map: Fn(usize) -> T,
490    Reduce: Fn(T, T) -> T,
491{
492    use std::panic::{catch_unwind, AssertUnwindSafe};
493    catch_unwind(AssertUnwindSafe(|| {
494        let mut accumulator = identity;
495        for index in 0..count {
496            accumulator = reduce(accumulator, map(index));
497        }
498        accumulator
499    }))
500    .map_err(|_| ExecutorError::SpawnFailed(moirai_core::error::TaskError::Panicked))
501}
502
503pub(super) fn map_reduce_range<T, Map, Reduce>(
504    start: usize,
505    end: usize,
506    identity: T,
507    map: &Map,
508    reduce: &Reduce,
509) -> T
510where
511    Map: Fn(usize) -> T,
512    Reduce: Fn(T, T) -> T,
513{
514    let mut accumulator = identity;
515    for index in start..end {
516        accumulator = reduce(accumulator, map(index));
517    }
518    accumulator
519}
520
521pub(super) fn indexed_chunk_count(count: usize, worker_count: usize) -> usize {
522    count.min(worker_count.max(1).saturating_add(1))
523}
524
525pub(super) fn indexed_chunk_bounds(
526    count: usize,
527    chunk_count: usize,
528    chunk_index: usize,
529) -> (usize, usize) {
530    let base = count / chunk_count;
531    let remainder = count % chunk_count;
532    let start = chunk_index * base + chunk_index.min(remainder);
533    let len = base + usize::from(chunk_index < remainder);
534    (start, start + len)
535}
536
537pub(super) fn lock_mutex<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
538    mutex
539        .lock()
540        .unwrap_or_else(|poisoned| poisoned.into_inner())
541}
542
543#[cfg(test)]
544mod indexed_chunk_count_tests {
545    use super::{indexed_chunk_bounds, indexed_chunk_count};
546
547    #[test]
548    fn assigns_small_domains_across_available_lanes() {
549        assert_eq!(indexed_chunk_count(9, 8), 9);
550        assert_eq!(indexed_chunk_count(2, 8), 2);
551        assert_eq!(indexed_chunk_count(1, 8), 1);
552        assert_eq!(indexed_chunk_count(0, 8), 0);
553    }
554
555    #[test]
556    fn caps_large_domains_at_workers_plus_caller() {
557        assert_eq!(indexed_chunk_count(1_000_000, 8), 9);
558    }
559
560    #[test]
561    fn single_worker_uses_worker_plus_caller() {
562        assert_eq!(indexed_chunk_count(1024, 1), 2);
563        assert_eq!(indexed_chunk_count(2, 1), 2);
564    }
565
566    #[test]
567    fn balances_remainder_across_every_chunk() {
568        let bounds: Vec<_> = (0..9)
569            .map(|chunk_index| indexed_chunk_bounds(10, 9, chunk_index))
570            .collect();
571        assert_eq!(
572            bounds,
573            vec![
574                (0, 2),
575                (2, 3),
576                (3, 4),
577                (4, 5),
578                (5, 6),
579                (6, 7),
580                (7, 8),
581                (8, 9),
582                (9, 10)
583            ]
584        );
585    }
586}