kevy_ring/lib.rs
1//! kevy-ring — a lock-free, bounded **single-producer / single-consumer** ring.
2//!
3//! One producer pushes, one consumer pops, with no locks and no per-message
4//! allocation: a fixed power-of-two slot array plus two monotonic cursors. It is
5//! the cross-core transport primitive for [kevy-rt]'s shared-nothing,
6//! thread-per-core runtime (the Seastar/Scylla model) — each ordered pair of
7//! cores gets its own ring, so a hot reactor never contends a lock on the hop.
8//!
9//! The single-producer / single-consumer contract is enforced *by the type
10//! system*: [`push`](Producer::push) and [`pop`](Consumer::pop) take `&mut self`,
11//! and [`Producer`]/[`Consumer`] are distinct owned halves, so the compiler
12//! guarantees at most one thread pushes and one pops. That keeps the ordering
13//! requirements minimal — a single `Release`/`Acquire` pair per operation.
14//!
15//! Pure Rust, zero dependencies. The lock-free buffer needs `UnsafeCell` +
16//! atomics, so this crate is not `#![forbid(unsafe_code)]`; every `unsafe` block
17//! documents the SPSC invariant it relies on (no C, no FFI — see the kevy
18//! pure-Rust principle). Part of the [kevy] key–value server.
19//!
20//! [kevy]: https://crates.io/crates/kevy
21//! [kevy-rt]: https://crates.io/crates/kevy-rt
22//!
23//! # Example
24//!
25//! ```
26//! let (mut tx, mut rx) = kevy_ring::ring::<u32>(4);
27//! assert!(tx.push(1).is_ok());
28//! assert!(tx.push(2).is_ok());
29//! assert_eq!(rx.pop(), Some(1));
30//! assert_eq!(rx.pop(), Some(2));
31//! assert_eq!(rx.pop(), None);
32//! ```
33//!
34//! Producer and consumer move to different threads:
35//!
36//! ```
37//! let (mut tx, mut rx) = kevy_ring::ring::<u64>(1024);
38//! let prod = std::thread::spawn(move || {
39//! for i in 0..10_000u64 {
40//! while tx.push(i).is_err() {
41//! std::hint::spin_loop(); // ring full — let the consumer drain
42//! }
43//! }
44//! });
45//! let mut next = 0u64;
46//! while next < 10_000 {
47//! if let Some(v) = rx.pop() {
48//! assert_eq!(v, next); // FIFO, nothing lost or reordered
49//! next += 1;
50//! }
51//! }
52//! prod.join().unwrap();
53//! ```
54
55#![warn(missing_docs)]
56// `--cfg loom` is a known custom cfg used by tests/loom.rs to swap atomics +
57// UnsafeCell + Arc for loom's instrumented versions. Tell rustc not to
58// warn about the unrecognized cfg name on normal builds.
59#![allow(unexpected_cfgs)]
60
61// Loom-compat shim: under `--cfg loom` the model checker substitutes its own
62// instrumented atomics + `UnsafeCell` + `Arc`, and `tests/loom.rs` enumerates
63// every possible interleaving of the SPSC algorithm against this exact crate.
64// Under a normal build the imports are the std originals — zero overhead.
65//
66// `UnsafeCellExt::with_mut` unifies the two APIs: loom's `UnsafeCell` only
67// exposes `with_mut`; std's exposes `.get()`. We give std a tiny shim that
68// delegates to `.get()` so the same call-site (`cell.with_mut(|p| ...)`) works
69// in both modes. No production code change in the hot path.
70mod sync {
71 #[cfg(loom)]
72 pub use loom::cell::UnsafeCell;
73 #[cfg(loom)]
74 pub use loom::sync::Arc;
75 #[cfg(loom)]
76 pub use loom::sync::atomic::{AtomicUsize, Ordering};
77
78 #[cfg(not(loom))]
79 pub use std::cell::UnsafeCell;
80 #[cfg(not(loom))]
81 pub use std::sync::Arc;
82 #[cfg(not(loom))]
83 pub use std::sync::atomic::{AtomicUsize, Ordering};
84}
85
86#[cfg(not(loom))]
87trait UnsafeCellExt<T> {
88 fn with_mut<F, R>(&self, f: F) -> R
89 where
90 F: FnOnce(*mut T) -> R;
91}
92#[cfg(not(loom))]
93impl<T> UnsafeCellExt<T> for std::cell::UnsafeCell<T> {
94 // The shim must vanish at every SPSC-ring hot site: with_mut wraps a
95 // single `UnsafeCell::get()` (one ptr load), so loom-vs-std API parity
96 // can't justify any call overhead in the producer/consumer fast loops.
97 #[allow(clippy::inline_always)]
98 #[inline(always)]
99 fn with_mut<F, R>(&self, f: F) -> R
100 where
101 F: FnOnce(*mut T) -> R,
102 {
103 f(self.get())
104 }
105}
106
107use std::mem::MaybeUninit;
108use sync::{Arc, AtomicUsize, Ordering, UnsafeCell};
109
110/// Pad to a cache line so the producer's `tail` and consumer's `head` never
111/// share one — otherwise each side's store would invalidate the other's cache
112/// line (false sharing) and erase the point of a lock-free ring. 128 bytes
113/// covers Apple-silicon's 128-byte prefetch pairs as well as x86's 64-byte line.
114#[repr(align(128))]
115struct CachePadded<T>(T);
116
117struct Ring<T> {
118 /// `capacity` slots; only indices in `[head, tail)` (mod capacity) are init.
119 buf: Box<[UnsafeCell<MaybeUninit<T>>]>,
120 /// `capacity - 1`; `capacity` is a power of two so `idx & mask` wraps.
121 mask: usize,
122 /// Next index to pop. Owned by the consumer; read by the producer.
123 head: CachePadded<AtomicUsize>,
124 /// Next index to push. Owned by the producer; read by the consumer.
125 tail: CachePadded<AtomicUsize>,
126}
127
128// SAFETY: the SPSC contract (enforced by `&mut self` on push/pop and the split
129// Producer/Consumer halves) means the producer only ever writes the slot at
130// `tail` and advances `tail`, while the consumer only reads the slot at `head`
131// and advances `head`. Those index ranges are disjoint, so the `UnsafeCell`
132// accesses never alias. A `T: Send` may thus cross the producer→consumer thread
133// boundary, making the shared `Ring` safe to `Send` and `Sync`.
134unsafe impl<T: Send> Send for Ring<T> {}
135unsafe impl<T: Send> Sync for Ring<T> {}
136
137impl<T> Ring<T> {
138 fn with_capacity(cap: usize) -> Self {
139 // At least 2 slots; round up to a power of two for masking.
140 let cap = cap.max(2).next_power_of_two();
141 let mut v = Vec::with_capacity(cap);
142 for _ in 0..cap {
143 v.push(UnsafeCell::new(MaybeUninit::uninit()));
144 }
145 Ring {
146 buf: v.into_boxed_slice(),
147 mask: cap - 1,
148 head: CachePadded(AtomicUsize::new(0)),
149 tail: CachePadded(AtomicUsize::new(0)),
150 }
151 }
152}
153
154impl<T> Drop for Ring<T> {
155 fn drop(&mut self) {
156 // Drop the elements still queued (indices `[head, tail)`); the rest are
157 // uninitialized and must not be touched.
158 let head = self.head.0.load(Ordering::Relaxed);
159 let tail = self.tail.0.load(Ordering::Relaxed);
160 let mut i = head;
161 while i != tail {
162 // SAFETY: `i` is in `[head, tail)`, so this slot holds an initialized
163 // `T` that no one else will read (we have `&mut self`).
164 self.buf[i & self.mask].with_mut(|p| unsafe {
165 (*p).assume_init_drop();
166 });
167 i = i.wrapping_add(1);
168 }
169 }
170}
171
172/// The sending half. `Send` (move to the producer thread); only this half pushes.
173pub struct Producer<T> {
174 inner: Arc<Ring<T>>,
175 /// Cached snapshot of the consumer's `head`. Stale-OK: a value the
176 /// consumer has already advanced past is still safe to treat as "head"
177 /// (the available-slot count we compute is then a conservative lower
178 /// bound, never letting us overwrite a live slot). Refreshed from the
179 /// shared `head` only when the cached count says the ring is full.
180 /// This is the SPSC fast-path lever — it amortises the cross-cache-line
181 /// `Acquire` load on the consumer's cursor over many pushes.
182 head_cache: usize,
183}
184
185/// The receiving half. `Send` (move to the consumer thread); only this half pops.
186pub struct Consumer<T> {
187 inner: Arc<Ring<T>>,
188 /// Cached snapshot of the producer's `tail`. Stale-OK in the same way as
189 /// [`Producer::head_cache`]: a value below the truth still lets us pop
190 /// safely (we just see fewer items than really exist; the next refresh
191 /// catches up). Refreshed only when the cached count says the ring is
192 /// empty.
193 tail_cache: usize,
194}
195
196/// Create a ring holding at least `capacity` items (rounded up to a power of
197/// two, minimum 2), returning its producer and consumer halves.
198pub fn ring<T>(capacity: usize) -> (Producer<T>, Consumer<T>) {
199 let r = Arc::new(Ring::with_capacity(capacity));
200 (
201 Producer {
202 inner: r.clone(),
203 head_cache: 0,
204 },
205 Consumer {
206 inner: r,
207 tail_cache: 0,
208 },
209 )
210}
211
212impl<T> Producer<T> {
213 /// Push `val`. Returns `Err(val)` (handing the value back) if the ring is
214 /// full, so the caller can retry after the consumer drains.
215 pub fn push(&mut self, val: T) -> Result<(), T> {
216 let r = &*self.inner;
217 // `tail` is ours: a plain Relaxed load suffices.
218 let tail = r.tail.0.load(Ordering::Relaxed);
219 // Fast path: trust the cached consumer head. If the cache says we
220 // have room, skip the shared `Acquire` load entirely.
221 if tail.wrapping_sub(self.head_cache) > r.mask {
222 // Cache says full — refresh from the shared cursor. `Acquire`
223 // pairs with the consumer's `Release` store of `head` so we
224 // observe slots it has freed before we reuse them.
225 self.head_cache = r.head.0.load(Ordering::Acquire);
226 if tail.wrapping_sub(self.head_cache) > r.mask {
227 return Err(val); // really full
228 }
229 }
230 // SAFETY: slot `tail & mask` is free (outside `[head, tail)`); we are
231 // the only producer, so no one else writes it.
232 r.buf[tail & r.mask].with_mut(|p| unsafe {
233 (*p).write(val);
234 });
235 // `Release` publishes the slot write to the consumer's `Acquire` of `tail`.
236 r.tail.0.store(tail.wrapping_add(1), Ordering::Release);
237 Ok(())
238 }
239
240 /// Whether the next [`push`](Self::push) would fail (ring full). Advisory:
241 /// the consumer may free a slot immediately after this returns.
242 pub fn is_full(&self) -> bool {
243 let r = &*self.inner;
244 let tail = r.tail.0.load(Ordering::Relaxed);
245 let head = r.head.0.load(Ordering::Acquire);
246 tail.wrapping_sub(head) > r.mask
247 }
248
249 /// Total slot count (a power of two ≥ 2).
250 pub fn capacity(&self) -> usize {
251 self.inner.mask + 1
252 }
253}
254
255impl<T> Consumer<T> {
256 /// Pop the oldest item, or `None` if the ring is empty.
257 pub fn pop(&mut self) -> Option<T> {
258 let r = &*self.inner;
259 // `head` is ours: Relaxed.
260 let head = r.head.0.load(Ordering::Relaxed);
261 // Fast path: trust the cached producer tail. If the cache says we
262 // have items, skip the shared `Acquire` load entirely.
263 if head == self.tail_cache {
264 // Cache says empty — refresh from the shared cursor. `Acquire`
265 // pairs with the producer's `Release` store of `tail` so the
266 // slot write is visible before we read it.
267 self.tail_cache = r.tail.0.load(Ordering::Acquire);
268 if head == self.tail_cache {
269 return None; // really empty
270 }
271 }
272 // SAFETY: slot `head & mask` is in `[head, tail)`, initialized by the
273 // producer; we are the only consumer, so we read it exactly once.
274 let val = r.buf[head & r.mask].with_mut(|p| unsafe { (*p).assume_init_read() });
275 // `Release` frees the slot for the producer's `Acquire` of `head`.
276 r.head.0.store(head.wrapping_add(1), Ordering::Release);
277 Some(val)
278 }
279
280 /// Whether the next [`pop`](Self::pop) would return `None` (ring empty).
281 /// Advisory: the producer may push immediately after this returns.
282 pub fn is_empty(&self) -> bool {
283 let r = &*self.inner;
284 let head = r.head.0.load(Ordering::Relaxed);
285 let tail = r.tail.0.load(Ordering::Acquire);
286 head == tail
287 }
288
289 /// Number of items currently queued. Advisory under concurrent producing.
290 pub fn len(&self) -> usize {
291 let r = &*self.inner;
292 let tail = r.tail.0.load(Ordering::Acquire);
293 let head = r.head.0.load(Ordering::Relaxed);
294 tail.wrapping_sub(head)
295 }
296
297 /// Total slot count (a power of two ≥ 2).
298 pub fn capacity(&self) -> usize {
299 self.inner.mask + 1
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use std::sync::atomic::{AtomicBool, Ordering};
307
308 #[test]
309 fn capacity_rounds_up_to_power_of_two() {
310 let (tx, rx) = ring::<u8>(3);
311 assert_eq!(tx.capacity(), 4);
312 // Consumer-side capacity must report the same slot count as the
313 // producer (both inspect the shared mask).
314 assert_eq!(rx.capacity(), tx.capacity());
315 let (tx, _rx) = ring::<u8>(1);
316 assert_eq!(tx.capacity(), 2); // minimum
317 let (tx, _rx) = ring::<u8>(1024);
318 assert_eq!(tx.capacity(), 1024);
319 }
320
321 #[test]
322 fn fifo_order_and_full_empty() {
323 let (mut tx, mut rx) = ring::<u32>(4); // 4 slots
324 assert!(rx.is_empty());
325 for i in 0..4 {
326 assert!(tx.push(i).is_ok());
327 }
328 assert!(tx.is_full());
329 assert_eq!(tx.push(99), Err(99)); // full → handed back
330 for i in 0..4 {
331 assert_eq!(rx.pop(), Some(i)); // FIFO
332 }
333 assert_eq!(rx.pop(), None);
334 assert!(rx.is_empty());
335 }
336
337 #[test]
338 fn wraps_around_many_times() {
339 // Push/pop far more than capacity to exercise index wrap.
340 let (mut tx, mut rx) = ring::<usize>(2);
341 for i in 0..10_000 {
342 assert!(tx.push(i).is_ok());
343 assert_eq!(rx.pop(), Some(i));
344 }
345 assert_eq!(rx.pop(), None);
346 }
347
348 #[test]
349 fn len_tracks_occupancy() {
350 let (mut tx, mut rx) = ring::<u8>(8);
351 assert_eq!(rx.len(), 0);
352 tx.push(1).unwrap();
353 tx.push(2).unwrap();
354 assert_eq!(rx.len(), 2);
355 rx.pop().unwrap();
356 assert_eq!(rx.len(), 1);
357 }
358
359 use std::sync::Arc as StdArc;
360
361 // Drop-counting payload used by `drops_queued_elements_exactly_once`.
362 struct Bomb(StdArc<AtomicUsize>);
363 impl Drop for Bomb {
364 fn drop(&mut self) {
365 self.0.fetch_add(1, Ordering::SeqCst);
366 }
367 }
368
369 #[test]
370 fn drops_queued_elements_exactly_once() {
371 // A payload that bumps a shared counter on drop; verify the ring's Drop
372 // releases exactly the still-queued items (no leak, no double free).
373 let dropped = StdArc::new(AtomicUsize::new(0));
374 {
375 let (mut tx, mut rx) = ring::<Bomb>(8);
376 for _ in 0..5 {
377 assert!(tx.push(Bomb(dropped.clone())).is_ok());
378 }
379 // Consume 2 (those drop now), leave 3 queued for the ring's Drop.
380 drop(rx.pop());
381 drop(rx.pop());
382 assert_eq!(dropped.load(Ordering::SeqCst), 2);
383 drop(tx);
384 drop(rx); // last handle → Ring dropped → remaining 3 dropped
385 }
386 assert_eq!(dropped.load(Ordering::SeqCst), 5);
387 }
388
389 #[test]
390 fn spsc_stress_across_threads() {
391 // Producer and consumer on separate threads; a small ring forces many
392 // full/empty transitions. Every item must arrive exactly once, in order.
393 // Miri interprets ~1000x slower than native; 2k iterations still cross
394 // the full/empty boundary dozens of times, which is what the race
395 // detector needs — large N only adds wall-clock, not coverage.
396 const N: u64 = if cfg!(miri) { 2_000 } else { 1_000_000 };
397 let (mut tx, mut rx) = ring::<u64>(64);
398 let producer = std::thread::spawn(move || {
399 for i in 0..N {
400 while tx.push(i).is_err() {
401 std::hint::spin_loop();
402 }
403 }
404 });
405 let mut next = 0u64;
406 while next < N {
407 match rx.pop() {
408 Some(v) => {
409 assert_eq!(v, next, "out-of-order or lost value");
410 next += 1;
411 }
412 None => std::hint::spin_loop(),
413 }
414 }
415 producer.join().unwrap();
416 assert_eq!(next, N);
417 }
418
419 #[test]
420 fn stress_with_intermittent_consumer() {
421 // Consumer occasionally stalls so the ring fills and the producer must
422 // back off — exercises the full path under real contention.
423 // Small N under miri for the same reason as `spsc_stress_across_threads`.
424 const N: u64 = if cfg!(miri) { 2_000 } else { 200_000 };
425 let (mut tx, mut rx) = ring::<u64>(16);
426 let done = Arc::new(AtomicBool::new(false));
427 let done_p = done.clone();
428 let producer = std::thread::spawn(move || {
429 for i in 0..N {
430 while tx.push(i).is_err() {
431 std::thread::yield_now();
432 }
433 }
434 done_p.store(true, Ordering::Release);
435 });
436 let mut next = 0u64;
437 let mut spins = 0u64;
438 loop {
439 if let Some(v) = rx.pop() {
440 assert_eq!(v, next);
441 next += 1;
442 spins += 1;
443 if spins.is_multiple_of(1000) {
444 std::thread::yield_now(); // let the ring fill up
445 }
446 } else {
447 if done.load(Ordering::Acquire) && rx.is_empty() {
448 break;
449 }
450 std::thread::yield_now();
451 }
452 }
453 producer.join().unwrap();
454 assert_eq!(next, N);
455 }
456}