Crossbeam SPSC ring buffers for 60 FPS Rust audio meters

7 min readYaseen Khatib · MERN + AI Architect

Why a lock-free SPSC ring buffer for audio meters

Audio callbacks must never block, allocate, or take locks. Your UI, meanwhile, ticks at ~60 Hz and can burst or stall. A bounded, single-producer/single-consumer (SPSC) ring gives you:

  • Constant-time, wait-free push/pop with only a couple of atomic ops
  • Bounded memory; no allocation after init
  • No syscalls (no parking/unparking); stable latency
  • Natural backpressure policy (drop when full) that’s perfect for meters

Crossbeam provides the right building blocks (CachePadded, Backoff, AtomicCell) and battle-tested atomics. We’ll build a compact SPSC ring tailored to RT audio → UI.

Design choices

  • SPSC only: one audio thread produces; one UI thread consumes
  • Bounded, power-of-two capacity with index masking (fast mod)
  • False-sharing resistance via CachePadded for head/tail
  • Safe, Copy-only payloads for meter frames (no drop complexity on RT path)
  • Drop-on-full write policy + pop_latest on UI to coalesce bursts

If you need general T with Drop, you can extend this with MaybeUninit and careful drop logic off the RT path; for meters, a small Copy struct is ideal.

Implementation

Cargo.toml (relevant parts):

[dependencies]
crossbeam-utils = "0.8"

SPSC ring buffer (Copy payload):

use core::mem::MaybeUninit;
use core::sync::atomic::{AtomicUsize, Ordering};
use crossbeam_utils::CachePadded;

pub struct SpscRing<T: Copy> {
    buf: Box<[MaybeUninit<T>]>,
    mask: usize,                   // capacity - 1 (power-of-two)
    head: CachePadded<AtomicUsize>,// write index (producer-only increments)
    tail: CachePadded<AtomicUsize>,// read index (consumer-only increments)
}

impl<T: Copy> SpscRing<T> {
    pub fn with_capacity_pow2(capacity: usize) -> Self {
        assert!(capacity >= 2 && capacity.is_power_of_two());
        let buf = {
            let mut v: Vec<MaybeUninit<T>> = Vec::with_capacity(capacity);
            // SAFETY: we’ll manage initialization manually; this is a fixed-size ring
            unsafe { v.set_len(capacity); }
            v.into_boxed_slice()
        };
        Self {
            mask: capacity - 1,
            buf,
            head: CachePadded::new(AtomicUsize::new(0)),
            tail: CachePadded::new(AtomicUsize::new(0)),
        }
    }

    #[inline]
    pub fn capacity(&self) -> usize { self.mask + 1 }

    #[inline]
    pub fn len(&self) -> usize {
        let head = self.head.load(Ordering::Acquire);
        let tail = self.tail.load(Ordering::Acquire);
        head - tail
    }

    // Producer: non-blocking; returns Err(value) if full
    #[inline]
    pub fn try_push(&self, value: T) -> Result<(), T> {
        let head = self.head.load(Ordering::Relaxed);
        let tail = self.tail.load(Ordering::Acquire);
        if head - tail == self.capacity() {
            return Err(value); // drop-on-full policy
        }
        let idx = head & self.mask;
        // SAFETY: single-producer ensures exclusive write to this slot
        unsafe { self.buf.get_unchecked(idx).as_ptr().write(value); }
        // Publish the write
        self.head.store(head.wrapping_add(1), Ordering::Release);
        Ok(())
    }

    // Consumer: non-blocking; None if empty
    #[inline]
    pub fn try_pop(&self) -> Option<T> {
        let tail = self.tail.load(Ordering::Relaxed);
        let head = self.head.load(Ordering::Acquire);
        if tail == head { return None; }
        let idx = tail & self.mask;
        // SAFETY: single-consumer ensures exclusive read from this slot
        let v = unsafe { self.buf.get_unchecked(idx).as_ptr().read() };
        // Publish the read
        self.tail.store(tail.wrapping_add(1), Ordering::Release);
        Some(v)
    }

    // Consumer convenience: drain queue and return the most recent frame
    #[inline]
    pub fn pop_latest(&self) -> Option<T> {
        let mut last = self.try_pop()?;
        while let Some(v) = self.try_pop() { last = v; }
        Some(last)
    }
}

unsafe impl<T: Copy + Send> Send for SpscRing<T> {}
unsafe impl<T: Copy + Send> Sync for SpscRing<T> {}

Notes on memory ordering:

  • Producer writes the slot, then Release-stores head. Consumer Acquire-loads head before reading, ensuring the slot write is visible.
  • Consumer reads the slot, then Release-stores tail. Producer Acquire-loads tail before checking capacity, ensuring it sees consumption.
  • CachePadded places head and tail on different cache lines, preventing false sharing and reducing store/load ping-pong.

Meter frame type

Meters are tiny Copy structs:

#[derive(Copy, Clone, Default)]
pub struct MeterFrame {
    pub peak_l: f32,
    pub peak_r: f32,
    pub rms_l: f32,
    pub rms_r: f32,
}

Wiring it into an audio callback and a 60 FPS UI

We’ll use cpal for the audio callback and a simple loop to emulate a 60 Hz UI. The key is: the producer must never block; the consumer coalesces with pop_latest().

use std::sync::Arc;
use std::time::{Duration, Instant};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};

fn main() -> anyhow::Result<()> {
    let ring = Arc::new(SpscRing::<MeterFrame>::with_capacity_pow2(64));

    // Audio setup (input or output callback depending on your app)
    let host = cpal::default_host();
    let device = host.default_input_device().expect("no input device");
    let config = device.default_input_config()?; // e.g., f32 mono/stereo

    let ring_p = ring.clone();
    let stream = match config.sample_format() {
        cpal::SampleFormat::F32 => build_stream::<f32>(&device, &config.into(), ring_p)?,
        cpal::SampleFormat::I16 => build_stream::<i16>(&device, &config.into(), ring_p)?,
        cpal::SampleFormat::U16 => build_stream::<u16>(&device, &config.into(), ring_p)?,
        _ => unimplemented!(),
    };
    stream.play()?;

    // UI loop @ ~60 FPS: pull latest frame, draw meters
    let mut last = MeterFrame::default();
    let frame = Duration::from_millis(16);
    let mut next = Instant::now();
    loop {
        if let Some(latest) = ring.pop_latest() { last = latest; }
        draw_meters(last); // your UI framework of choice
        next += frame;
        let now = Instant::now();
        if next > now { std::thread::sleep(next - now); } else { next = now; }
    }
}

fn build_stream<T>(device: &cpal::Device, config: &cpal::StreamConfig, ring: Arc<SpscRing<MeterFrame>>)
    -> Result<cpal::Stream, cpal::BuildStreamError>
where
    T: cpal::Sample + cpal::FromSample<f32>,
{
    let channels = config.channels as usize;
    device.build_input_stream(
        config,
        move |data: &[T], _| {
            // RT callback: compute per-block meters
            let mut peak_l = 0.0f32; let mut peak_r = 0.0f32;
            let mut sumsq_l = 0.0f32; let mut sumsq_r = 0.0f32; let mut n = 0u32;
            if channels == 1 {
                for &s in data { let x: f32 = s.to_sample();
                    let a = x.abs(); if a > peak_l { peak_l = a; }
                    sumsq_l += x * x; n += 1;
                }
                // mirror mono to R
                peak_r = peak_l; sumsq_r = sumsq_l;
            } else {
                for frame in data.chunks_exact(channels) {
                    let l: f32 = frame[0].to_sample();
                    let r: f32 = frame[1].to_sample();
                    let al = l.abs(); let ar = r.abs();
                    if al > peak_l { peak_l = al; }
                    if ar > peak_r { peak_r = ar; }
                    sumsq_l += l * l; sumsq_r += r * r; n += 1;
                }
            }
            if n > 0 {
                let invn = 1.0 / (n as f32);
                let frame = MeterFrame {
                    peak_l,
                    peak_r,
                    rms_l: (sumsq_l * invn).sqrt(),
                    rms_r: (sumsq_r * invn).sqrt(),
                };
                let _ = ring.try_push(frame); // drop on full: UI will coalesce
            }
        },
        move |err| eprintln!("stream error: {err}"),
        None,
    )
}

fn draw_meters(m: MeterFrame) {
    // Replace with egui, wgpu, iced, etc. Here we just log occasionally.
    static mut CNT: u32 = 0;
    unsafe {
        CNT += 1;
        if CNT % 60 == 0 { println!("peak L/R: {:.3} {:.3} | rms L/R: {:.3} {:.3}", m.peak_l, m.peak_r, m.rms_l, m.rms_r); }
    }
}

This pattern keeps the audio thread allocation- and lock-free. The UI gets the freshest meter each frame, even if the audio callback runs 200–1000 Hz.

Why not crossbeam channels or ArrayQueue?

  • crossbeam-channel (bounded) is excellent for general MPMC, but it may park/unpark under contention and does more bookkeeping than needed for SPSC. Any parking is death in a real-time callback.
  • crossbeam_queue::ArrayQueue is fast and lock-free for MPMC, but SPSC lets you shave overhead: you only need one writer and one reader index, with no ABA issues or tickets.
  • A bespoke SPSC ring gives predictable cache behavior and fewer atomics.

If you must ship yesterday and can tolerate a few extra atomics, ArrayQueue works; still prefer drop-on-full + pop_latest to keep meters smooth.

Pitfalls and tuning

  • Capacity sizing: 32–256 frames is plenty for meters. Too small risks frequent drops; too large increases latency when the UI hiccups.
  • Power-of-two capacity simplifies the fast path (mask instead of %). Don’t fight it.
  • Keep payload Copy and small (<= 32 bytes) to avoid cache misses and complicated drops.
  • No cross-thread tail updates: for SPSC, only the consumer increments tail. Overwriting full buffers from the producer by moving tail is possible but requires careful synchronization to avoid racing the consumer.
  • Overflow of head/tail counters is theoretical on 64-bit (2^64 wraps after centuries at kHz). If you care, wrap with wrapping_add and rely on signed distance in comparisons as shown.

Micro-benchmark sketch

For a feel, a release build on a modern x86_64 should do a push+pop in ~5–20 ns per element for a small Copy payload. You can measure with criterion:

// Cargo.toml: criterion = "0.5"
use criterion::{criterion_group, criterion_main, Criterion, black_box};

fn bench_spsc(c: &mut Criterion) {
    let ring = SpscRing::<u64>::with_capacity_pow2(1024);
    c.bench_function("spsc push+pop", |b| {
        b.iter(|| {
            let v = black_box(42u64);
            let _ = ring.try_push(v);
            let _ = ring.try_pop();
        })
    });
}
criterion_group!(benches, bench_spsc);
criterion_main!(benches);

Takeaways

  • For RT audio → UI, a tiny SPSC ring is the right tool: bounded, wait-free, no syscalls.
  • crossbeam’s CachePadded and atomics make a robust implementation trivial.
  • Drop-on-full plus pop_latest prevents UI stalls from growing latency while keeping meters responsive.
  • Resist the convenience of general-purpose channels on the RT path; save them for orchestration off the hot loop.

Looking to architect a similar system?

Let's ship it at AI-speed.

Start a conversation →