Skip to main content

subetha_cxc/
mpsc_ring.rs

1//! `SharedRingMpsc` - composed multi-producer / single-consumer
2//! ring built from N independent Lamport SPSC rings.
3//!
4//! Each producer is a sole-writer to its own [`SpscRingCore`]. The
5//! single consumer drains all N rings round-robin. Per-push cost
6//! is one Acquire load + one Release store (pure Lamport SPSC).
7//! Per-pop cost is the same on the successful ring, plus an
8//! Acquire-load on each empty ring the consumer skips before
9//! finding a non-empty one.
10//!
11//! # When this beats Vyukov MPMC
12//!
13//! `SharedRing` (the existing Vyukov MPMC primitive) is the right
14//! choice when callers need **global FIFO order across all
15//! producers**. `SharedRingMpsc` only preserves **per-producer
16//! FIFO**: items from producer A and producer B can interleave at
17//! the consumer based on which ring the consumer drained first.
18//!
19//! For most fan-in workloads (task queues, log shipping, metric
20//! aggregation, result collection), per-producer FIFO is what
21//! callers actually need. Saving the consumer-side CAS that
22//! Vyukov pays buys ~2x consumer-side throughput on this class
23//! of workload.
24//!
25//! # Compile-time enforcement
26//!
27//! The consumer handle is `!Sync + !Clone + Send` so the compiler
28//! guarantees a single consumer at runtime. Producer handles are
29//! `!Sync + !Clone + Send` per-handle; callers receive a `Vec` of
30//! them at construction and distribute them to producer threads
31//! (each handle moves to its dedicated thread).
32
33use std::cell::Cell;
34use std::marker::PhantomData;
35use std::path::Path;
36use std::sync::Arc;
37use std::sync::atomic::{AtomicUsize, Ordering};
38
39use crate::shared_ring::{RingError, SharedRing};
40use crate::spsc_ring::SpscRingCore;
41
42/// Factory for an MPSC pool composed from N Lamport SPSC rings.
43pub struct SharedRingMpsc;
44
45/// A single producer handle. Sole writer to one underlying SPSC
46/// ring; `!Sync + !Clone + Send` so the compiler enforces that one
47/// thread owns one producer handle.
48pub struct MpscProducer {
49    inner: Arc<SpscRingCore>,
50    _not_sync: PhantomData<Cell<()>>,
51}
52
53/// The single consumer handle. Drains all N producer rings
54/// round-robin; `!Sync + !Clone + Send` so the compiler enforces
55/// a single consumer.
56pub struct MpscConsumer {
57    rings: Vec<Arc<SpscRingCore>>,
58    /// Round-robin cursor: index of the ring to try first on next
59    /// `try_pop`. Avoids always hammering ring 0 first under steady
60    /// load; gives each producer a fair share of the consumer's
61    /// attention.
62    next_drain: AtomicUsize,
63    _not_sync: PhantomData<Cell<()>>,
64}
65
66impl SharedRingMpsc {
67    /// Anonymous in-memory pool of N producer rings + one consumer.
68    /// Skips file create + ftruncate; in-process only.
69    pub fn create_anon_pool(
70        n_producers: usize,
71        capacity: usize,
72    ) -> Result<(Vec<MpscProducer>, MpscConsumer), RingError> {
73        assert!(n_producers >= 1, "n_producers must be >= 1");
74        let mut rings: Vec<Arc<SpscRingCore>> = Vec::with_capacity(n_producers);
75        for _ in 0..n_producers {
76            rings.push(Arc::new(SpscRingCore::create_anon(capacity)?));
77        }
78        let producers: Vec<MpscProducer> = rings
79            .iter()
80            .map(|r| MpscProducer {
81                inner: Arc::clone(r),
82                _not_sync: PhantomData,
83            })
84            .collect();
85        let consumer = MpscConsumer {
86            rings,
87            next_drain: AtomicUsize::new(0),
88            _not_sync: PhantomData,
89        };
90        Ok((producers, consumer))
91    }
92
93    /// File-backed pool: one file per producer ring. Each ring's
94    /// path is derived from `path_prefix` by appending `.{i}.bin`.
95    pub fn create_pool(
96        path_prefix: impl AsRef<Path>,
97        n_producers: usize,
98        capacity: usize,
99    ) -> Result<(Vec<MpscProducer>, MpscConsumer), RingError> {
100        assert!(n_producers >= 1, "n_producers must be >= 1");
101        let mut rings: Vec<Arc<SpscRingCore>> = Vec::with_capacity(n_producers);
102        let base = path_prefix.as_ref().to_path_buf();
103        for i in 0..n_producers {
104            let path = ring_path(&base, i);
105            rings.push(Arc::new(SpscRingCore::create(&path, capacity)?));
106        }
107        let producers: Vec<MpscProducer> = rings
108            .iter()
109            .map(|r| MpscProducer {
110                inner: Arc::clone(r),
111                _not_sync: PhantomData,
112            })
113            .collect();
114        let consumer = MpscConsumer {
115            rings,
116            next_drain: AtomicUsize::new(0),
117            _not_sync: PhantomData,
118        };
119        Ok((producers, consumer))
120    }
121
122    /// Open an existing file-backed pool. Caller passes the same
123    /// path_prefix + n_producers + capacity the pool was created
124    /// with.
125    pub fn open_pool(
126        path_prefix: impl AsRef<Path>,
127        n_producers: usize,
128        expected_capacity: usize,
129    ) -> Result<(Vec<MpscProducer>, MpscConsumer), RingError> {
130        assert!(n_producers >= 1, "n_producers must be >= 1");
131        let mut rings: Vec<Arc<SpscRingCore>> = Vec::with_capacity(n_producers);
132        let base = path_prefix.as_ref().to_path_buf();
133        for i in 0..n_producers {
134            let path = ring_path(&base, i);
135            rings.push(Arc::new(SpscRingCore::open(&path, expected_capacity)?));
136        }
137        let producers: Vec<MpscProducer> = rings
138            .iter()
139            .map(|r| MpscProducer {
140                inner: Arc::clone(r),
141                _not_sync: PhantomData,
142            })
143            .collect();
144        let consumer = MpscConsumer {
145            rings,
146            next_drain: AtomicUsize::new(0),
147            _not_sync: PhantomData,
148        };
149        Ok((producers, consumer))
150    }
151}
152
153fn ring_path(prefix: &std::path::Path, i: usize) -> std::path::PathBuf {
154    let mut s = prefix.as_os_str().to_owned();
155    s.push(format!(".{i}.bin"));
156    std::path::PathBuf::from(s)
157}
158
159impl MpscProducer {
160    /// Push one payload to this producer's ring. Pure Lamport SPSC.
161    pub fn try_push(&self, payload: &[u8]) -> Result<(), RingError> {
162        self.inner.try_push(payload)
163    }
164
165    /// Capacity of this producer's ring (always a power of 2).
166    pub fn capacity(&self) -> usize {
167        self.inner.capacity()
168    }
169
170    /// Current head of this producer's ring (own published position).
171    pub fn head(&self) -> u64 {
172        self.inner.head()
173    }
174}
175
176impl MpscConsumer {
177    /// Drain one item, round-robin across all N producer rings.
178    /// Returns `Ok` on the first ring that has an item;
179    /// `Err(Empty)` only if every ring is empty.
180    pub fn try_pop(&self, out: &mut [u8]) -> Result<usize, RingError> {
181        let n = self.rings.len();
182        let start = self.next_drain.load(Ordering::Relaxed);
183        for i in 0..n {
184            let idx = (start + i) % n;
185            if let Ok(bytes) = self.rings[idx].try_pop(out) {
186                // Advance the cursor past the ring we just drained so
187                // the NEXT call starts at idx+1; producer fairness.
188                self.next_drain.store((idx + 1) % n, Ordering::Relaxed);
189                return Ok(bytes);
190            }
191        }
192        Err(RingError::Empty)
193    }
194
195    /// Number of producer rings this consumer drains.
196    pub fn n_producers(&self) -> usize {
197        self.rings.len()
198    }
199
200    /// Total approximate items waiting across all producer rings.
201    pub fn approx_total_len(&self) -> usize {
202        self.rings.iter().map(|r| r.approx_len()).sum()
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Single-ring MPSC variant that preserves global FIFO.
208// ---------------------------------------------------------------------------
209
210/// Factory for a single-ring MPSC pool that preserves **global FIFO
211/// ordering across all producers**. Producers contend on one
212/// Vyukov-MPMC `producer_seq` (CAS retries on push) while the
213/// single consumer skips the consumer-side CAS by using the
214/// `try_pop_spsc` fast path on the underlying [`SharedRing`].
215///
216/// # When this beats [`SharedRingMpsc`] (the composed variant)
217///
218/// `SharedRingMpsc` is faster on the producer side for any N
219/// because each producer owns its own ring (zero CAS contention).
220/// `SharedRingMpscFifo` wins on the consumer side because the
221/// consumer drains one ring instead of round-robining N. The
222/// crossover depends on:
223///
224/// - **N (producer count)**: low N favours `Fifo` (light producer
225///   CAS contention + no consumer round-robin); high N favours
226///   `Composed` (independent producer rings, no shared CAS).
227/// - **ordering requirement**: `Fifo` is the only choice when the
228///   caller needs global FIFO across all producers (e.g. a totally-
229///   ordered event log). `Composed` only preserves per-producer FIFO.
230///
231/// `examples/mpmc_shootout.rs` benches both side by side at common
232/// (N producers, 1 consumer) shapes; pick by measurement.
233pub struct SharedRingMpscFifo;
234
235/// One producer handle on a [`SharedRingMpscFifo`] pool. Sole
236/// owner of one slot of the producer pool; `!Sync + !Clone + Send`.
237pub struct MpscFifoProducer {
238    inner: Arc<SharedRing>,
239    _not_sync: PhantomData<Cell<()>>,
240}
241
242/// The single consumer handle. Uses the consumer-side SPSC fast
243/// path on the shared Vyukov ring; `!Sync + !Clone + Send`.
244pub struct MpscFifoConsumer {
245    inner: Arc<SharedRing>,
246    _not_sync: PhantomData<Cell<()>>,
247}
248
249impl SharedRingMpscFifo {
250    /// Anonymous in-memory single-ring MPSC pool.
251    pub fn create_anon_pool(
252        n_producers: usize,
253        capacity: usize,
254    ) -> Result<(Vec<MpscFifoProducer>, MpscFifoConsumer), RingError> {
255        assert!(n_producers >= 1, "n_producers must be >= 1");
256        let ring = Arc::new(SharedRing::create_anon(capacity)?);
257        let producers: Vec<MpscFifoProducer> = (0..n_producers)
258            .map(|_| MpscFifoProducer {
259                inner: Arc::clone(&ring),
260                _not_sync: PhantomData,
261            })
262            .collect();
263        let consumer = MpscFifoConsumer {
264            inner: ring,
265            _not_sync: PhantomData,
266        };
267        Ok((producers, consumer))
268    }
269
270    /// File-backed single-ring MPSC pool. One backing file (not
271    /// one per producer), so cross-process layout is identical to
272    /// a single [`SharedRing`].
273    pub fn create_pool(
274        path: impl AsRef<Path>,
275        n_producers: usize,
276        capacity: usize,
277    ) -> Result<(Vec<MpscFifoProducer>, MpscFifoConsumer), RingError> {
278        assert!(n_producers >= 1, "n_producers must be >= 1");
279        let ring = Arc::new(SharedRing::create(path, capacity)?);
280        let producers: Vec<MpscFifoProducer> = (0..n_producers)
281            .map(|_| MpscFifoProducer {
282                inner: Arc::clone(&ring),
283                _not_sync: PhantomData,
284            })
285            .collect();
286        let consumer = MpscFifoConsumer {
287            inner: ring,
288            _not_sync: PhantomData,
289        };
290        Ok((producers, consumer))
291    }
292
293    /// Open an existing file-backed single-ring MPSC pool.
294    pub fn open_pool(
295        path: impl AsRef<Path>,
296        n_producers: usize,
297        expected_capacity: usize,
298    ) -> Result<(Vec<MpscFifoProducer>, MpscFifoConsumer), RingError> {
299        assert!(n_producers >= 1, "n_producers must be >= 1");
300        let ring = Arc::new(SharedRing::open(path, expected_capacity)?);
301        let producers: Vec<MpscFifoProducer> = (0..n_producers)
302            .map(|_| MpscFifoProducer {
303                inner: Arc::clone(&ring),
304                _not_sync: PhantomData,
305            })
306            .collect();
307        let consumer = MpscFifoConsumer {
308            inner: ring,
309            _not_sync: PhantomData,
310        };
311        Ok((producers, consumer))
312    }
313}
314
315impl MpscFifoProducer {
316    /// Push one payload via the Vyukov MPMC producer-side protocol
317    /// (CAS on `producer_seq` to claim a slot, write payload, then
318    /// Release-store the slot's sequence number to publish).
319    pub fn try_push(&self, payload: &[u8]) -> Result<(), RingError> {
320        self.inner.try_push(payload)
321    }
322}
323
324impl MpscFifoConsumer {
325    /// Pop one payload using the single-consumer fast path on the
326    /// shared Vyukov ring. Skips the consumer-side CAS that the
327    /// MPMC `try_pop` needs to defend against racing consumers.
328    /// Sound here because the `!Sync + !Clone` type contract
329    /// guarantees this is the only consumer.
330    pub fn try_pop(&self, out: &mut [u8]) -> Result<usize, RingError> {
331        self.inner.try_pop_spsc(out)
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::spsc_ring::SPSC_PAYLOAD_BYTES;
339    use std::thread;
340
341    #[test]
342    fn create_anon_pool_round_trip() {
343        let (producers, consumer) = SharedRingMpsc::create_anon_pool(4, 8).unwrap();
344        assert_eq!(producers.len(), 4);
345        assert_eq!(consumer.n_producers(), 4);
346
347        // Each producer pushes one item with a value matching its index.
348        for (i, p) in producers.iter().enumerate() {
349            let mut buf = [0u8; SPSC_PAYLOAD_BYTES];
350            buf[..4].copy_from_slice(&(i as u32).to_le_bytes());
351            p.try_push(&buf).unwrap();
352        }
353
354        // Drain 4 items; each producer's value appears exactly once.
355        let mut seen = [false; 4];
356        let mut out = [0u8; SPSC_PAYLOAD_BYTES];
357        for _ in 0..4 {
358            consumer.try_pop(&mut out).unwrap();
359            let v = u32::from_le_bytes(out[..4].try_into().unwrap()) as usize;
360            assert!(v < 4, "got value {v} outside producer index range");
361            assert!(!seen[v], "value {v} appeared twice");
362            seen[v] = true;
363        }
364        assert!(seen.iter().all(|&s| s), "not every producer delivered");
365        assert_eq!(consumer.try_pop(&mut out).unwrap_err(), RingError::Empty);
366    }
367
368    #[test]
369    fn concurrent_producers_lose_no_items() {
370        let (producers, consumer) = SharedRingMpsc::create_anon_pool(4, 64).unwrap();
371        const PER_PRODUCER: u32 = 10_000;
372
373        // Spawn one thread per producer.
374        let producer_handles: Vec<_> = producers
375            .into_iter()
376            .enumerate()
377            .map(|(pid, p)| {
378                thread::spawn(move || {
379                    for i in 0..PER_PRODUCER {
380                        let mut buf = [0u8; SPSC_PAYLOAD_BYTES];
381                        // Encode (producer_id, sequence) so we can verify
382                        // per-producer FIFO and total count.
383                        buf[..4].copy_from_slice(&(pid as u32).to_le_bytes());
384                        buf[4..8].copy_from_slice(&i.to_le_bytes());
385                        while p.try_push(&buf).is_err() {
386                            std::hint::spin_loop();
387                        }
388                    }
389                })
390            })
391            .collect();
392
393        // Consumer drains; tracks (per-producer next-expected sequence).
394        let n_producers = consumer.n_producers();
395        let consumer_handle = thread::spawn(move || -> (u32, Vec<u32>) {
396            let mut next: Vec<u32> = vec![0; n_producers];
397            let mut total: u32 = 0;
398            let target = PER_PRODUCER * n_producers as u32;
399            let mut out = [0u8; SPSC_PAYLOAD_BYTES];
400            while total < target {
401                if consumer.try_pop(&mut out).is_ok() {
402                    let pid = u32::from_le_bytes(out[..4].try_into().unwrap()) as usize;
403                    let seq = u32::from_le_bytes(out[4..8].try_into().unwrap());
404                    assert_eq!(
405                        seq, next[pid],
406                        "per-producer FIFO violated for producer {pid}: expected {} got {}",
407                        next[pid], seq,
408                    );
409                    next[pid] += 1;
410                    total += 1;
411                } else {
412                    std::hint::spin_loop();
413                }
414            }
415            (total, next)
416        });
417
418        for h in producer_handles {
419            h.join().unwrap();
420        }
421        let (total, next) = consumer_handle.join().unwrap();
422        assert_eq!(total, PER_PRODUCER * 4);
423        assert_eq!(next, vec![PER_PRODUCER; 4]);
424    }
425
426    #[test]
427    fn round_robin_is_fair() {
428        // With 3 producers each pushing 1 item, the consumer should
429        // see items in the round-robin drain order rather than always
430        // ring 0 first.
431        let (producers, consumer) = SharedRingMpsc::create_anon_pool(3, 4).unwrap();
432        for (i, p) in producers.iter().enumerate() {
433            let mut buf = [0u8; SPSC_PAYLOAD_BYTES];
434            buf[..4].copy_from_slice(&(i as u32).to_le_bytes());
435            p.try_push(&buf).unwrap();
436        }
437        let mut order = Vec::new();
438        let mut out = [0u8; SPSC_PAYLOAD_BYTES];
439        while consumer.try_pop(&mut out).is_ok() {
440            order.push(u32::from_le_bytes(out[..4].try_into().unwrap()));
441        }
442        // First pop should be ring 0 (cursor starts there); next pops
443        // visit 1, 2 in order due to the cursor advance.
444        assert_eq!(order, vec![0, 1, 2]);
445    }
446}