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 cannot block, allocate, or take locks. The UI runs around 60 Hz and can jitter. A bounded single-producer/single-consumer ring fits that split:

  • Constant-time, wait-free push/pop with only a couple of atomics
  • Bounded memory with no allocation after init
  • No syscalls (no parking/unparking), so latency stays flat
  • A simple backpressure story (drop when full) that suits meters

Crossbeam has the pieces you want here (CachePadded, Backoff, AtomicCell) with solid atomics. The goal is a compact SPSC ring dedicated to RT audio → UI.

Design choices

  • SPSC only: one audio thread produces; one UI thread consumes
  • Bounded, power-of-two capacity with index masking for cheap modulo
  • Avoid false sharing by padding head/tail with CachePadded
  • Copy-only payloads for meter frames, so the RT path never runs Drop
  • Drop-on-full on write, and pop_latest on the UI to merge bursts

If you truly need a general T with Drop, add MaybeUninit and do the drop work off the RT thread. For meters, a small Copy struct is the right trade.

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 so the write is visible.
  • Consumer reads the slot, then Release-stores tail. Producer Acquire-loads tail before checking capacity so it observes consumption.
  • CachePadded keeps head and tail on separate cache lines to avoid false sharing and reduce 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

Use cpal for the audio callback and a simple 60 Hz loop on the UI side. 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 keeps the audio thread allocation-free and lock-free. The UI always paints the freshest meter each frame, even with a 200–1000 Hz callback.

Why not crossbeam channels or ArrayQueue?

  • crossbeam-channel (bounded) is great MPMC infrastructure, but it may park/unpark under contention and does extra bookkeeping you don’t need for SPSC. Any parking in a real-time callback is a non-starter.
  • crossbeam_queue::ArrayQueue is fast and lock-free for MPMC, but SPSC lets you drop the extra indices/tickets and shave atomics.
  • A small SPSC ring gives predictable cache behavior and fewer atomic ops.

If you need something working immediately and can afford a few extra atomics, ArrayQueue is acceptable; still pair it with drop-on-full and pop_latest to keep meters smooth.

Pitfalls and tuning

  • Capacity: 32–256 frames is usually enough for meters. Too small drops more often; too large adds latency during UI hiccups.
  • Use a power-of-two capacity and mask instead of %. It simplifies the hot path.
  • Keep the payload Copy and small (<= 32 bytes) to stay cache-friendly and avoid drops on the RT thread.
  • Don’t update tail from the producer: in SPSC only the consumer moves tail. Forcibly advancing tail to overwrite when full is possible, but you must synchronize carefully to avoid racing the consumer.
  • Head/tail counter overflow is theoretical on 64-bit (2^64 wraps after centuries at kHz). Use wrapping_add and distance comparisons as shown.

Micro-benchmark sketch

On a modern x86_64 in release, expect roughly 5–20 ns per push+pop for a small Copy payload. To check, use 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 straightforward.
  • Drop-on-full with pop_latest keeps meters responsive without growing latency when the UI stalls.
  • Avoid general-purpose channels on the RT path; use them for orchestration away from the hot loop.

Need an engineer who can build this?

I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.