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}