5 · Shaping the data Draft

Batching in the SPSC ring

7 min read

On this page

Batching

A batch is several items handed over for the price of one. push_batch writes several slots, then stores head once, with Release, which orders every write before it: a consumer whose Acquire load sees the new head sees every item, and the head line crosses once per batch instead of once per item. It checks room against cached_tail, refreshes it at most once, and takes an item from the iterator only when it has a slot for it, so what does not fit stays in the iterator, and the count returned says how many went in. drain is the consumer’s half: one Acquire load of head, every slot up to it read and its item passed to a closure f, and one Release store of tail at the end. It takes what is there now and never waits: on an empty ring it returns 0 at once and stores nothing, and it never holds back to let a batch grow.

The whole ring:

use std::{cell::UnsafeCell, mem::MaybeUninit, sync::Arc};
use std::sync::atomic::{AtomicU64, Ordering::{Acquire, Relaxed, Release}};

/// One counter alone on its 64-byte line.
#[repr(align(64))]
struct Padded(AtomicU64);

pub struct Ring<T, const N: usize> {
    head: Padded,                                // items written: stored only by the producer
    tail: Padded,                                // items read: stored only by the consumer
    slots: Box<[UnsafeCell<MaybeUninit<T>>; N]>, // allocated once, never grows
}

// SAFETY: a slot is touched by one side at a time, as the counters hand it over,
// and items cross by value, so it is enough that T may be sent between threads.
unsafe impl<T: Send, const N: usize> Sync for Ring<T, N> {}

impl<T, const N: usize> Ring<T, N> {
    fn slot(&self, count: u64) -> *mut MaybeUninit<T> {
        self.slots[(count as usize) & (N - 1)].get() // a mask, not a division
    }
}

impl<T, const N: usize> Drop for Ring<T, N> {
    fn drop(&mut self) {                          // both ends are gone: drop what is left
        for c in self.tail.0.load(Relaxed)..self.head.0.load(Relaxed) {
            unsafe { (*self.slot(c)).assume_init_drop() };
        }
    }
}

pub struct Producer<T, const N: usize> { ring: Arc<Ring<T, N>>, cached_tail: u64 }
pub struct Consumer<T, const N: usize> { ring: Arc<Ring<T, N>>, cached_head: u64 }

pub fn ring<T, const N: usize>() -> (Producer<T, N>, Consumer<T, N>) {
    const { assert!(N.is_power_of_two(), "ring capacity must be a power of two") };
    let slots: Box<[_]> = (0..N).map(|_| UnsafeCell::new(MaybeUninit::uninit())).collect();
    let ring = Arc::new(Ring {
        head: Padded(AtomicU64::new(0)),
        tail: Padded(AtomicU64::new(0)),
        slots: slots.try_into().expect("exactly N slots"),
    });
    (Producer { ring: Arc::clone(&ring), cached_tail: 0 }, Consumer { ring, cached_head: 0 })
}

impl<T, const N: usize> Producer<T, N> {
    pub fn try_push(&mut self, v: T) -> Result<(), T> {
        let head = self.ring.head.0.load(Relaxed);             // our own last store
        if head - self.cached_tail == N as u64 {               // full, by our copy
            self.cached_tail = self.ring.tail.0.load(Acquire); // only now read tail's line
            if head - self.cached_tail == N as u64 {
                return Err(v);                                 // really full
            }
        }
        unsafe { (*self.ring.slot(head)).write(v) };
        self.ring.head.0.store(head + 1, Release);             // publish one item
        Ok(())
    }

    pub fn push_batch(&mut self, items: &mut impl Iterator<Item = T>) -> usize {
        let start = self.ring.head.0.load(Relaxed);
        let (mut head, mut refreshed) = (start, false);
        loop {
            if head - self.cached_tail == N as u64 {           // full, by our copy
                if refreshed { break; }                        // looked once already
                refreshed = true;
                self.cached_tail = self.ring.tail.0.load(Acquire);
                continue;
            }
            let Some(v) = items.next() else { break };
            unsafe { (*self.ring.slot(head)).write(v) };
            head += 1;
        }
        if head != start {
            self.ring.head.0.store(head, Release);             // publish them all at once
        }
        (head - start) as usize
    }
}

/// Stores `tail` when dropped, so that if `drain`'s closure panics, the items
/// already handed to it are not dropped a second time by the ring.
struct StoreOnDrop<'a>(&'a AtomicU64, u64);
impl Drop for StoreOnDrop<'_> { fn drop(&mut self) { self.0.store(self.1, Release) } }

impl<T, const N: usize> Consumer<T, N> {
    pub fn try_pop(&mut self) -> Option<T> {
        let tail = self.ring.tail.0.load(Relaxed);             // our own last store
        if tail == self.cached_head {                          // empty, by our copy
            self.cached_head = self.ring.head.0.load(Acquire); // only now read head's line
            if tail == self.cached_head {
                return None;                                   // really empty
            }
        }
        let v = unsafe { (*self.ring.slot(tail)).assume_init_read() };
        self.ring.tail.0.store(tail + 1, Release);             // hand the slot back
        Some(v)
    }

    pub fn drain(&mut self, mut f: impl FnMut(T)) -> usize {
        let start = self.ring.tail.0.load(Relaxed);
        self.cached_head = self.ring.head.0.load(Acquire);     // one look at head
        if start == self.cached_head {
            return 0;                                          // nothing there: no store
        }
        let mut tail = StoreOnDrop(&self.ring.tail.0, start);
        while tail.1 != self.cached_head {
            let v = unsafe { (*self.ring.slot(tail.1)).assume_init_read() };
            tail.1 += 1;                                       // counted before f runs
            f(v);
        }
        (tail.1 - start) as usize                              // one Release store, on drop
    }
}

ring() returns the two halves, a Producer that can only push and a Consumer that can only pop, each holding an Arc, a reference-counted pointer, to the one Ring, and its own copy of the other side’s counter. A slot is an UnsafeCell, the one type through which memory reached by a shared reference may be written, around a MaybeUninit<T>, storage that may not hold a value yet: write fills it, assume_init_read moves the value out, assume_init_drop drops it in place, and uninitialised memory’s rules come later in the chapter. The compiler cannot see that the counters keep the two sides off each other’s slots, so unsafe impl Sync asserts it, requiring only T: Send since items cross by value. Producer and Consumer are then Send by the compiler’s own rules, and neither is Clone, so there is exactly one of each: the type system keeps the ring single-producer and single-consumer. The Box<[_; N]> tells the compiler the length is N, so it can drop the bounds check on slots[c & (N - 1)]. The last Arc to go drops the ring, whose Drop drops the items still between tail and head, with Relaxed loads because the Arc has already made both threads’ writes visible. StoreOnDrop stores tail as it goes out of scope, at the end of drain or while a panic in f unwinds, so no item is dropped twice. After ring() nothing allocates: the counting global allocator shows zero calls for any number of pushes.

The standard library’s channel shows what generality costs. std::sync::mpsc has been, since Rust 1.67 in January 2023, a port of the crossbeam-channel crate’s implementation, built for many producers and many consumers. The unbounded channel() is a linked list of blocks of 31 messages, 32 positions with the last marking the block’s end, and the sender that fills a block allocates the next on the hot path. Each send is a compare-and-swap on the shared tail index and a read-modify-write on the slot’s written-and-read flags; each receive does the same on the head side, and the flags’ line is written by both threads. A blocking recv on an empty channel puts its thread to sleep, on Linux on a futex, and the next send pays the wake. The bounded sync_channel(n) is a fixed array, nearer this ring, but still a compare-and-swap per operation, since it cannot assume one thread at each end. A Mutex<VecDeque> moves the lock word’s line to whichever thread takes it, and the indices and data after it, doubles when it outgrows its capacity, and under collision has a tail running from a microsecond at the p99 to milliseconds at the max. Two production designs are worth reading. crossbeam-queue’s ArrayQueue is Dmitry Vyukov’s bounded queue for many producers and consumers: padded head and tail, a stamp in every slot saying which lap may use it next, and a compare-and-swap per operation, the price of the generality this ring declines. rtrb, a “real-time ring buffer”, is this SPSC ring, wait-free and allocation-free after construction, with CachePadded counters and each side caching the other’s position. It allows any capacity, keeping positions in 0 to 2N and subtracting with a compare where this ring masks, and it adds chunked access for batches: write_chunk_uninit(n) and read_chunk(n) hand out n slots as two slices, two because a run of slots can wrap past the array’s end, and publish them with one store.