subetha_cxc/reactor.rs
1//! `reactor`: the bridge that makes a SubEtha ring a first-class async
2//! source ACROSS processes, not just across threads.
3//!
4//! Intra-process async is direct: the producer holds the consumer's
5//! `Waker` and fires it on push (see [`crate::waker_ring`]). Across
6//! processes the producer is in another address space and cannot touch
7//! a local `Waker`, so a parked future needs something in THIS process
8//! to notice the cross-process publish and fire its `Waker`. That is
9//! the reactor: one background thread per process that blocks on the
10//! MMF [`CrossProcessWaker`], and when another process publishes, fires
11//! the local `Waker` of the
12//! future parked on the ring. It is the epoll/IOCP reactor pattern with
13//! the readiness source being a shared-memory ring head instead of a
14//! socket.
15//!
16//! # One surface, two locales
17//!
18//! [`ReactiveReceiver::recv`] returns the same future whether the
19//! producer is a thread or a process:
20//! - [`anon_pair`] builds an intra-process channel; the sender fires
21//! the receiver's `Waker` directly, no reactor thread.
22//! - [`receiver_cross`] / [`sender_cross`] build the cross-process
23//! halves over a shared MMF ring + named waker; a reactor thread in
24//! the consumer bridges the publish to the local `Waker`.
25//!
26//! Unlike [`crate::async_ring`], which spawns one OS thread per
27//! in-flight future, the reactor uses ONE thread per process regardless
28//! of how many futures park on the ring.
29//!
30//! # `block_on`
31//!
32//! [`block_on`] is a minimal thread-parking driver: it sleeps the
33//! calling thread between polls and is unparked by the future's `Waker`.
34//! Paired with the reactor, a consumer process genuinely sleeps (both
35//! the driver thread and the reactor thread park in the kernel) until
36//! another process publishes - no busy-spin.
37
38use std::future::Future;
39use std::pin::Pin;
40use std::sync::Arc;
41use std::sync::atomic::{AtomicBool, Ordering};
42use std::task::{Context, Poll, Wake, Waker};
43use std::thread::JoinHandle;
44use std::time::Duration;
45
46use parking_lot::Mutex;
47
48use crate::cross_process_waker::CrossProcessWaker;
49use crate::shared_ring::RingError;
50use crate::spsc_ring::{SpscRingCore, SPSC_PAYLOAD_BYTES};
51
52/// Maximum the reactor sleeps per wait before re-checking the ring head,
53/// independent of the cross-process wake. The wake (the common path)
54/// returns far sooner; this tick only matters when a wake is lost to the
55/// `CrossProcessWaker` register/wake visibility race, which the head
56/// re-check at the loop top then heals. Bounded, so a lost wake cannot
57/// hang the consumer; large enough that an idle reactor barely ticks.
58const REACTOR_HEAL_INTERVAL: Duration = Duration::from_millis(50);
59
60/// A `Waker` that unparks a specific thread. The driver behind
61/// [`block_on`].
62struct ThreadWaker {
63 thread: std::thread::Thread,
64}
65
66impl Wake for ThreadWaker {
67 fn wake(self: Arc<Self>) {
68 self.thread.unpark();
69 }
70 fn wake_by_ref(self: &Arc<Self>) {
71 self.thread.unpark();
72 }
73}
74
75/// Drive a future to completion on the current thread, parking the
76/// thread between polls. The future's `Waker` unparks it; a reactor
77/// (or a local sender) fires that `Waker` on readiness.
78pub fn block_on<F: Future>(future: F) -> F::Output {
79 let mut future = Box::pin(future);
80 let waker = Waker::from(Arc::new(ThreadWaker {
81 thread: std::thread::current(),
82 }));
83 let mut cx = Context::from_waker(&waker);
84 loop {
85 match future.as_mut().poll(&mut cx) {
86 Poll::Ready(v) => return v,
87 // A spurious unpark just re-polls (which re-checks the ring
88 // and re-parks), so this is correct without a flag.
89 Poll::Pending => std::thread::park(),
90 }
91 }
92}
93
94/// Where a sender's push delivers its readiness signal.
95enum SenderSignal {
96 /// Intra-process: fire the receiver's `Waker` directly.
97 Local(Arc<Mutex<Option<Waker>>>),
98 /// Cross-process: wake the consumer's reactor through the MMF.
99 Cross(Arc<CrossProcessWaker>),
100}
101
102/// Producer half. `try_send` publishes the payload and signals the
103/// consumer - a direct `Waker` fire intra-process, an MMF wake
104/// cross-process.
105pub struct ReactiveSender {
106 ring: Arc<SpscRingCore>,
107 signal: SenderSignal,
108}
109
110impl ReactiveSender {
111 /// Push a payload and signal the consumer. Returns `Err(Full)` when
112 /// the ring is full (the signal is sent only on a successful push).
113 pub fn try_send(&self, payload: &[u8]) -> Result<(), RingError> {
114 self.ring.try_push(payload)?;
115 match &self.signal {
116 SenderSignal::Local(slot) => {
117 if let Some(w) = slot.lock().take() {
118 w.wake();
119 }
120 }
121 SenderSignal::Cross(xwaker) => {
122 xwaker.wake_up_to(self.ring.head());
123 }
124 }
125 Ok(())
126 }
127
128 /// The producer's published item count (ring head).
129 pub fn published(&self) -> u64 {
130 self.ring.head()
131 }
132}
133
134/// Consumer half. `recv()` is an `.await`-able future that resolves
135/// when an item arrives, suspending the task until then - off-thread
136/// across threads OR across processes, behind the same call.
137pub struct ReactiveReceiver {
138 ring: Arc<SpscRingCore>,
139 slot: Arc<Mutex<Option<Waker>>>,
140 /// Present only in cross-process mode; owns the reactor thread and
141 /// stops it on drop.
142 _reactor: Option<ReactorHandle>,
143}
144
145impl ReactiveReceiver {
146 /// A future resolving to the next slot's bytes. Owns clones of the
147 /// ring + waker slot, so it is `Send + 'static`.
148 pub fn recv(&self) -> ReactiveRecv {
149 ReactiveRecv {
150 ring: Arc::clone(&self.ring),
151 slot: Arc::clone(&self.slot),
152 }
153 }
154
155 /// Non-blocking pop, for draining without awaiting.
156 pub fn try_recv(&self, out: &mut [u8]) -> Result<usize, RingError> {
157 self.ring.try_pop(out)
158 }
159}
160
161/// Future returned by [`ReactiveReceiver::recv`].
162pub struct ReactiveRecv {
163 ring: Arc<SpscRingCore>,
164 slot: Arc<Mutex<Option<Waker>>>,
165}
166
167impl Future for ReactiveRecv {
168 type Output = [u8; SPSC_PAYLOAD_BYTES];
169
170 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
171 let mut out = [0u8; SPSC_PAYLOAD_BYTES];
172 if self.ring.try_pop(&mut out).is_ok() {
173 return Poll::Ready(out);
174 }
175 // Register, then re-check: an item that landed between the first
176 // pop and this registration is caught here, not lost.
177 *self.slot.lock() = Some(cx.waker().clone());
178 if self.ring.try_pop(&mut out).is_ok() {
179 return Poll::Ready(out);
180 }
181 Poll::Pending
182 }
183}
184
185/// Owns the reactor thread and stops it when the receiver drops.
186struct ReactorHandle {
187 shutdown: Arc<AtomicBool>,
188 xwaker: Arc<CrossProcessWaker>,
189 join: Option<JoinHandle<()>>,
190}
191
192impl Drop for ReactorHandle {
193 fn drop(&mut self) {
194 self.shutdown.store(true, Ordering::Release);
195 // Unblock the reactor's wait() so it sees the shutdown flag.
196 self.xwaker.wake_all();
197 if let Some(j) = self.join.take() {
198 j.join().ok();
199 }
200 }
201}
202
203/// Intra-process reactive channel: the sender fires the receiver's
204/// `Waker` directly on push. No reactor thread. `capacity` must be a
205/// power of two.
206pub fn anon_pair(
207 capacity: usize,
208) -> Result<(ReactiveSender, ReactiveReceiver), RingError> {
209 let ring = Arc::new(SpscRingCore::create_anon(capacity)?);
210 let slot = Arc::new(Mutex::new(None));
211 Ok((
212 ReactiveSender {
213 ring: Arc::clone(&ring),
214 signal: SenderSignal::Local(Arc::clone(&slot)),
215 },
216 ReactiveReceiver { ring, slot, _reactor: None },
217 ))
218}
219
220/// Cross-process producer half over a shared MMF ring + named waker.
221/// The two processes share the same ring file and the same waker file.
222pub fn sender_cross(
223 ring: Arc<SpscRingCore>,
224 xwaker: Arc<CrossProcessWaker>,
225) -> ReactiveSender {
226 ReactiveSender { ring, signal: SenderSignal::Cross(xwaker) }
227}
228
229/// Cross-process consumer half. Spawns a reactor thread that blocks on
230/// the shared waker and fires the local `Waker` of the future parked on
231/// the ring whenever the producer process publishes.
232pub fn receiver_cross(
233 ring: Arc<SpscRingCore>,
234 xwaker: Arc<CrossProcessWaker>,
235) -> ReactiveReceiver {
236 let slot: Arc<Mutex<Option<Waker>>> = Arc::new(Mutex::new(None));
237 let shutdown = Arc::new(AtomicBool::new(false));
238
239 let join = {
240 let ring = Arc::clone(&ring);
241 let xwaker = Arc::clone(&xwaker);
242 let slot = Arc::clone(&slot);
243 let shutdown = Arc::clone(&shutdown);
244 std::thread::spawn(move || reactor_loop(ring, xwaker, slot, shutdown))
245 };
246
247 ReactiveReceiver {
248 ring,
249 slot,
250 _reactor: Some(ReactorHandle {
251 shutdown,
252 xwaker,
253 join: Some(join),
254 }),
255 }
256}
257
258/// The reactor: bridge cross-process publishes to the local `Waker`.
259/// Blocks on the MMF waker; on every observed head advance fires the
260/// parked future's `Waker` so the driver re-polls and pops.
261fn reactor_loop(
262 ring: Arc<SpscRingCore>,
263 xwaker: Arc<CrossProcessWaker>,
264 slot: Arc<Mutex<Option<Waker>>>,
265 shutdown: Arc<AtomicBool>,
266) {
267 let mut last = ring.head();
268 loop {
269 if shutdown.load(Ordering::Acquire) {
270 break;
271 }
272 let head = ring.head();
273 if head != last {
274 last = head;
275 if let Some(w) = slot.lock().take() {
276 w.wake();
277 }
278 continue;
279 }
280 // Park until the producer publishes past `head`.
281 match xwaker.try_park(head + 1) {
282 Ok(token) => {
283 // Lost-wake guard: an item that landed (or a shutdown
284 // that fired) between the head read and the park is
285 // caught here.
286 if shutdown.load(Ordering::Acquire) || ring.head() != head {
287 xwaker.release(token);
288 continue;
289 }
290 // Heal-bounded: a real cross-process wake (producer
291 // publish) or the shutdown `wake_all` ends the wait fast;
292 // the bounded tick is the backstop so a wake lost to the
293 // register/visibility race self-heals at the loop top
294 // (head re-check) instead of hanging the consumer.
295 xwaker.wait(token, Some(REACTOR_HEAL_INTERVAL)).ok();
296 }
297 Err(crate::cross_process_waker::WakerError::Full) => {
298 // No free waker slot; re-check shortly.
299 std::hint::spin_loop();
300 }
301 Err(_) => break,
302 }
303 }
304}
305
306/// A bridge from an arbitrary monotonic published-seq source to a local
307/// `Waker` slot, for channels whose backing is not an `SpscRingCore`
308/// (e.g. a [`SharedRing`](crate::SharedRing)'s `producer_seq` /
309/// `consumer_seq`). Same heal-bounded loop as [`reactor_loop`]; the
310/// closure supplies the count. Stops its thread on drop.
311pub(crate) struct SeqReactor {
312 shutdown: Arc<AtomicBool>,
313 xwaker: Arc<CrossProcessWaker>,
314 join: Option<JoinHandle<()>>,
315}
316
317impl Drop for SeqReactor {
318 fn drop(&mut self) {
319 self.shutdown.store(true, Ordering::Release);
320 self.xwaker.wake_all();
321 if let Some(j) = self.join.take() {
322 j.join().ok();
323 }
324 }
325}
326
327/// Spawn a reactor firing `slot` whenever `published()` advances,
328/// parking on `xwaker` between observations.
329pub(crate) fn spawn_seq_reactor(
330 published: Arc<dyn Fn() -> u64 + Send + Sync>,
331 xwaker: Arc<CrossProcessWaker>,
332 slot: Arc<Mutex<Option<Waker>>>,
333) -> SeqReactor {
334 let shutdown = Arc::new(AtomicBool::new(false));
335 let join = {
336 let xwaker = Arc::clone(&xwaker);
337 let shutdown = Arc::clone(&shutdown);
338 std::thread::spawn(move || seq_reactor_loop(published, &xwaker, &slot, &shutdown))
339 };
340 SeqReactor { shutdown, xwaker, join: Some(join) }
341}
342
343fn seq_reactor_loop(
344 published: Arc<dyn Fn() -> u64 + Send + Sync>,
345 xwaker: &CrossProcessWaker,
346 slot: &Mutex<Option<Waker>>,
347 shutdown: &AtomicBool,
348) {
349 let mut last = published();
350 loop {
351 if shutdown.load(Ordering::Acquire) {
352 break;
353 }
354 let cur = published();
355 if cur != last {
356 last = cur;
357 if let Some(w) = slot.lock().take() {
358 w.wake();
359 }
360 continue;
361 }
362 match xwaker.try_park(cur + 1) {
363 Ok(token) => {
364 if shutdown.load(Ordering::Acquire) || published() != cur {
365 xwaker.release(token);
366 continue;
367 }
368 xwaker.wait(token, Some(REACTOR_HEAL_INTERVAL)).ok();
369 }
370 Err(crate::cross_process_waker::WakerError::Full) => {
371 std::hint::spin_loop();
372 }
373 Err(_) => break,
374 }
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use std::sync::atomic::{AtomicU64, Ordering};
382 use std::thread;
383
384 #[test]
385 fn intra_process_block_on_parks_and_wakes_on_send() {
386 // The driver thread parks between items; the sender's direct
387 // Waker fire unparks it. Proves the recv future suspends rather
388 // than spins, with no reactor thread in this locale.
389 let (tx, rx) = anon_pair(8).unwrap();
390 const N: u64 = 1000;
391
392 let producer = thread::spawn(move || {
393 for i in 0..N {
394 let mut buf = [0u8; SPSC_PAYLOAD_BYTES];
395 buf[..8].copy_from_slice(&i.to_le_bytes());
396 while tx.try_send(&buf).is_err() {
397 std::hint::spin_loop();
398 }
399 }
400 });
401
402 let sum = block_on(async move {
403 let mut s = 0u64;
404 for _ in 0..N {
405 let item = rx.recv().await;
406 s += u64::from_le_bytes(item[..8].try_into().unwrap());
407 }
408 s
409 });
410
411 producer.join().unwrap();
412 assert_eq!(sum, (0..N).sum());
413 }
414
415 #[test]
416 fn many_futures_one_local_signal() {
417 // A second consumer task (driven on a worker) also wakes on the
418 // same channel's sender fire; verifies the slot-register /
419 // re-check path under interleaving.
420 let (tx, rx) = anon_pair(4).unwrap();
421 let got = Arc::new(AtomicU64::new(0));
422 let got2 = Arc::clone(&got);
423
424 let consumer = thread::spawn(move || {
425 block_on(async move {
426 for _ in 0..500u64 {
427 let item = rx.recv().await;
428 got2.fetch_add(
429 u64::from_le_bytes(item[..8].try_into().unwrap()),
430 Ordering::AcqRel,
431 );
432 }
433 });
434 });
435
436 for i in 0..500u64 {
437 let mut buf = [0u8; SPSC_PAYLOAD_BYTES];
438 buf[..8].copy_from_slice(&i.to_le_bytes());
439 while tx.try_send(&buf).is_err() {
440 std::hint::spin_loop();
441 }
442 }
443 consumer.join().unwrap();
444 assert_eq!(got.load(Ordering::Acquire), (0..500u64).sum());
445 }
446}