Skip to main content

subetha_cxc/
ring_executor.rs

1//! `RingExecutor`: an async executor whose READY QUEUE is built from
2//! SubEtha rings. The future's handle rides through a ring; the ring IS
3//! the scheduler, not a data channel beside one.
4//!
5//! This is the deeper async shape than [`crate::waker_ring`]. There the
6//! ring carries message bytes and the future lives in a heap queue
7//! (`Mutex<VecDeque>` in [`crate::task_pool`]). Here the future's
8//! `Arc<Task>` handle is the ring payload: a worker pops a handle,
9//! reconstructs the `Arc`, polls it; a `wake()` pushes the handle back
10//! into a ring. Scheduling = a ring push; running = a ring pop.
11//!
12//! # Shape-adaptive ready queue
13//!
14//! The ready queue is NOT one ring. A single Vyukov ring funnels every
15//! core's CAS through one counter and walls throughput as cores climb
16//! (Vyukov contention rises sharply past a handful of producers). So
17//! the executor shards the ready queue to the hardware: ONE ready-ring
18//! shard per worker, worker count taken from
19//! [`std::thread::available_parallelism`] (or supplied explicitly).
20//! Each worker owns a home shard, drains it first, and STEALS from the
21//! other shards round-robin when its own is empty. A task is homed to
22//! one shard round-robin at spawn and always reschedules there, so a
23//! self-waking task's handle stays on one ring (locality) and the home
24//! worker is almost always the only thread touching it. The ring count
25//! equals the core count: 1 core -> 1 ring, a 44-thread host -> 44
26//! rings, no single-counter wall.
27//!
28//! Workers pin to distinct cores best-effort
29//! ([`crate::cpu_affinity`]); on a host without an affinity API they
30//! run unpinned.
31//!
32//! # Why this answers "uncapped consumers"
33//!
34//! WORKERS are the hardware parallelism - a small, fixed cap matched to
35//! the machine. TASKS are unbounded: they are `Arc<Task>` handles
36//! multiplexed onto the worker pool through the rings, not threads. A
37//! 44-thread host drives an arbitrary task population on 44 workers.
38//!
39//! # Handle / refcount discipline
40//!
41//! A task is in its home ring at most once, gated by a `scheduled`
42//! flag:
43//!  - `spawn` / `wake` flip `scheduled` false->true and push one
44//!    `Arc::into_raw` handle. A redundant wake (flag already true) drops
45//!    its clone instead of double-pushing.
46//!  - a worker pops a handle, `Arc::from_raw` reclaims that ref, clears
47//!    `scheduled`, and polls. `Ready` drops the future and the run-ref;
48//!    `Pending` drops the run-ref, leaving the future's stashed waker
49//!    clone as the liveness anchor until the next wake re-pushes.
50//!
51//! Because each live task occupies at most one slot of its home shard,
52//! a shard sized to `>= peak tasks homed there` never returns `Full`;
53//! the round-robin home spread keeps that at about `peak_tasks /
54//! shards`. A `Full` push (only possible under an adversarial
55//! liveness/home correlation) spins until a stealer drains the shard,
56//! which is deadlock-free whenever more than one worker runs.
57
58use std::future::Future;
59use std::pin::Pin;
60use std::sync::Arc;
61use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
62use std::task::{Context, Wake, Waker};
63use std::thread::JoinHandle;
64
65use parking_lot::Mutex;
66
67use crate::shared_ring::{SharedRing, PAYLOAD_BYTES};
68
69/// One scheduled unit of work. The future is `Option`-wrapped so a
70/// completed task drops its future and any later (spurious) wake that
71/// re-pushes the handle finds `None` and skips it.
72struct Task {
73    future: Mutex<Option<Pin<Box<dyn Future<Output = ()> + Send>>>>,
74    /// This task's home ready-ring shard; it always schedules here.
75    home: Arc<SharedRing>,
76    /// Live count of not-yet-complete tasks, shared with the executor;
77    /// decremented exactly once when this task first returns `Ready`.
78    pending: Arc<AtomicUsize>,
79    /// True while a handle for this task sits in its home ring. Gates
80    /// the at-most-one-handle-per-task invariant.
81    scheduled: AtomicBool,
82}
83
84impl Task {
85    /// Enqueue this task into its home ready shard, at most once. A
86    /// second caller while a handle is already queued drops its clone
87    /// instead of pushing a duplicate.
88    fn schedule(self: &Arc<Self>) {
89        if self.scheduled.swap(true, Ordering::AcqRel) {
90            return;
91        }
92        let raw = Arc::into_raw(Arc::clone(self)) as usize as u64;
93        let mut buf = [0u8; PAYLOAD_BYTES];
94        buf[..8].copy_from_slice(&raw.to_le_bytes());
95        // Home shard is sized to its expected peak load, so this push
96        // almost never fails; if an adversarial home/liveness skew fills
97        // it, spin until a stealing worker drains a slot.
98        while self.home.try_push(&buf).is_err() {
99            std::hint::spin_loop();
100        }
101    }
102}
103
104impl Wake for Task {
105    fn wake(self: Arc<Self>) {
106        self.schedule();
107    }
108    fn wake_by_ref(self: &Arc<Self>) {
109        self.schedule();
110    }
111}
112
113/// A hardware-shaped pool of worker threads draining an unbounded set
114/// of tasks through per-worker SubEtha ready-ring shards.
115pub struct RingExecutor {
116    shards: Arc<Vec<Arc<SharedRing>>>,
117    pending: Arc<AtomicUsize>,
118    shutdown: Arc<AtomicBool>,
119    next_home: AtomicUsize,
120    pinned: Arc<AtomicUsize>,
121    workers: Vec<JoinHandle<()>>,
122    shard_capacity: usize,
123}
124
125impl RingExecutor {
126    /// Build an executor whose worker count matches the host's logical
127    /// core count ([`std::thread::available_parallelism`]). The ready
128    /// queue gets one shard per worker; `max_tasks` is the peak number
129    /// of simultaneously-live tasks the queue must hold.
130    pub fn with_available_parallelism(max_tasks: usize) -> Self {
131        let n = std::thread::available_parallelism()
132            .map(|x| x.get())
133            .unwrap_or(1);
134        Self::new(n, max_tasks)
135    }
136
137    /// Build an executor with `n_workers` worker threads (one ready
138    /// shard each) and a ready queue sized to hold at least `max_tasks`
139    /// simultaneously-live task handles, spread across the shards.
140    pub fn new(n_workers: usize, max_tasks: usize) -> Self {
141        let n = n_workers.max(1);
142        // Each shard holds about an even slice of the peak live set.
143        let per_shard = max_tasks.div_ceil(n).max(1);
144        let shard_capacity = per_shard.next_power_of_two().max(2);
145
146        let shards: Vec<Arc<SharedRing>> = (0..n)
147            .map(|_| {
148                Arc::new(
149                    SharedRing::create_anon(shard_capacity)
150                        .expect("ready shard alloc"),
151                )
152            })
153            .collect();
154        let shards = Arc::new(shards);
155        let pending = Arc::new(AtomicUsize::new(0));
156        let shutdown = Arc::new(AtomicBool::new(false));
157        let pinned = Arc::new(AtomicUsize::new(0));
158        // Startup barrier: every worker bumps `ready` after its pin
159        // attempt, so by the time `new` returns the pinned count is
160        // final and the pool is fully spun up.
161        let ready = Arc::new(AtomicUsize::new(0));
162
163        let workers = (0..n)
164            .map(|w| {
165                let shards = Arc::clone(&shards);
166                let shutdown = Arc::clone(&shutdown);
167                let pinned = Arc::clone(&pinned);
168                let ready = Arc::clone(&ready);
169                std::thread::spawn(move || {
170                    if crate::cpu_affinity::pin_current_thread_to_core(w) {
171                        pinned.fetch_add(1, Ordering::AcqRel);
172                    }
173                    ready.fetch_add(1, Ordering::AcqRel);
174                    worker_loop(shards, w, shutdown);
175                })
176            })
177            .collect();
178
179        while ready.load(Ordering::Acquire) < n {
180            std::hint::spin_loop();
181        }
182
183        Self {
184            shards,
185            pending,
186            shutdown,
187            next_home: AtomicUsize::new(0),
188            pinned,
189            workers,
190            shard_capacity,
191        }
192    }
193
194    /// Spawn a future. It runs to completion on the pool, suspending
195    /// (off-thread) whenever it awaits, with no thread dedicated to it.
196    pub fn spawn(&self, future: impl Future<Output = ()> + Send + 'static) {
197        self.pending.fetch_add(1, Ordering::AcqRel);
198        let home_idx =
199            self.next_home.fetch_add(1, Ordering::Relaxed) % self.shards.len();
200        let task = Arc::new(Task {
201            future: Mutex::new(Some(Box::pin(future))),
202            home: Arc::clone(&self.shards[home_idx]),
203            pending: Arc::clone(&self.pending),
204            scheduled: AtomicBool::new(false),
205        });
206        task.schedule();
207    }
208
209    /// Number of tasks not yet complete.
210    pub fn pending(&self) -> usize {
211        self.pending.load(Ordering::Acquire)
212    }
213
214    /// Number of worker threads (one ready shard each).
215    pub fn worker_count(&self) -> usize {
216        self.workers.len()
217    }
218
219    /// Number of ready-ring shards (equals worker count).
220    pub fn shard_count(&self) -> usize {
221        self.shards.len()
222    }
223
224    /// How many workers were pinned to a distinct core (best-effort;
225    /// 0 on hosts without an affinity API). Read after the workers have
226    /// started.
227    pub fn pinned_workers(&self) -> usize {
228        self.pinned.load(Ordering::Acquire)
229    }
230
231    /// Per-shard slot capacity (power of two).
232    pub fn shard_capacity(&self) -> usize {
233        self.shard_capacity
234    }
235
236    /// Spin until every spawned task has completed.
237    pub fn wait_idle(&self) {
238        while self.pending() > 0 {
239            std::hint::spin_loop();
240        }
241    }
242
243    /// Stop the workers (after the rings drain) and join them. Call
244    /// once the spawned work has completed.
245    pub fn shutdown(self) {
246        self.shutdown.store(true, Ordering::Release);
247        for w in self.workers {
248            w.join().ok();
249        }
250    }
251}
252
253/// Poll one task handle popped from a ready shard. Reclaims the ref the
254/// producer transferred into the ring, polls once, and on completion
255/// drops the future and decrements the live count.
256fn run_handle(raw: usize) {
257    let task = unsafe { Arc::from_raw(raw as *const Task) };
258    // Allow a wake during this poll to re-schedule the task.
259    task.scheduled.store(false, Ordering::Release);
260
261    let mut guard = task.future.lock();
262    if let Some(fut) = guard.as_mut() {
263        let waker = Waker::from(Arc::clone(&task));
264        let mut cx = Context::from_waker(&waker);
265        if fut.as_mut().poll(&mut cx).is_ready() {
266            *guard = None;
267            drop(guard);
268            task.pending.fetch_sub(1, Ordering::AcqRel);
269        }
270    }
271    // Dropping `task` releases the run-ref. If the poll returned
272    // Pending, the future's stashed waker clone keeps the task alive
273    // until the next wake re-pushes a handle.
274}
275
276fn worker_loop(
277    shards: Arc<Vec<Arc<SharedRing>>>,
278    home: usize,
279    shutdown: Arc<AtomicBool>,
280) {
281    let s = shards.len();
282    let mut buf = [0u8; PAYLOAD_BYTES];
283    loop {
284        // Home shard first, then steal round-robin from the others.
285        let mut ran = false;
286        for k in 0..s {
287            let idx = (home + k) % s;
288            if shards[idx].try_pop(&mut buf).is_ok() {
289                let raw =
290                    u64::from_le_bytes(buf[..8].try_into().unwrap()) as usize;
291                run_handle(raw);
292                ran = true;
293                break;
294            }
295        }
296        if !ran {
297            if shutdown.load(Ordering::Acquire) {
298                break;
299            }
300            std::hint::spin_loop();
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use std::sync::atomic::AtomicU64;
309
310    /// A future that re-schedules itself `left` times before completing,
311    /// so each yield is one round-trip of the task handle through a
312    /// ready shard.
313    struct YieldN {
314        left: u32,
315    }
316    impl Future for YieldN {
317        type Output = ();
318        fn poll(
319            mut self: Pin<&mut Self>,
320            cx: &mut Context<'_>,
321        ) -> std::task::Poll<()> {
322            if self.left == 0 {
323                std::task::Poll::Ready(())
324            } else {
325                self.left -= 1;
326                cx.waker().wake_by_ref();
327                std::task::Poll::Pending
328            }
329        }
330    }
331
332    #[test]
333    fn many_tasks_few_workers_through_the_rings() {
334        // Tasks >> workers: the shards multiplex an unbounded task set
335        // onto a fixed worker pool. Each task yields several times, so
336        // handles cycle through their home shard repeatedly; an idle
337        // worker steals from other shards.
338        let n_tasks = 20_000u64;
339        let exec = RingExecutor::new(4, n_tasks as usize);
340        assert_eq!(exec.worker_count(), 4);
341        assert_eq!(exec.shard_count(), 4);
342
343        let done = Arc::new(AtomicU64::new(0));
344        for _ in 0..n_tasks {
345            let done = Arc::clone(&done);
346            exec.spawn(async move {
347                YieldN { left: 5 }.await;
348                done.fetch_add(1, Ordering::AcqRel);
349            });
350        }
351
352        let start = std::time::Instant::now();
353        while done.load(Ordering::Acquire) < n_tasks {
354            if start.elapsed() > std::time::Duration::from_secs(30) {
355                panic!("only {} of {n_tasks} tasks completed",
356                       done.load(Ordering::Acquire));
357            }
358            std::hint::spin_loop();
359        }
360        assert_eq!(done.load(Ordering::Acquire), n_tasks);
361        exec.wait_idle();
362        exec.shutdown();
363    }
364
365    #[test]
366    fn completes_with_single_worker() {
367        // One worker, one shard: a self-waking task re-enters its home
368        // ring and the same worker picks it up. No stealing needed.
369        let exec = RingExecutor::new(1, 4_000);
370        assert_eq!(exec.shard_count(), 1);
371        let done = Arc::new(AtomicU64::new(0));
372        for _ in 0..4_000u64 {
373            let done = Arc::clone(&done);
374            exec.spawn(async move {
375                YieldN { left: 3 }.await;
376                done.fetch_add(1, Ordering::AcqRel);
377            });
378        }
379        exec.wait_idle();
380        assert_eq!(done.load(Ordering::Acquire), 4_000);
381        exec.shutdown();
382    }
383
384    #[test]
385    fn adapts_worker_count_to_hardware() {
386        // The auto-detected constructor sizes workers to the host; the
387        // shard count tracks it, and the task set still drains.
388        let exec = RingExecutor::with_available_parallelism(2_000);
389        assert!(exec.worker_count() >= 1);
390        assert_eq!(exec.shard_count(), exec.worker_count());
391        let done = Arc::new(AtomicU64::new(0));
392        for _ in 0..2_000u64 {
393            let done = Arc::clone(&done);
394            exec.spawn(async move {
395                YieldN { left: 2 }.await;
396                done.fetch_add(1, Ordering::AcqRel);
397            });
398        }
399        exec.wait_idle();
400        assert_eq!(done.load(Ordering::Acquire), 2_000);
401        exec.shutdown();
402    }
403}