Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rs-ringbuffer

A lock-free single-producer/single-consumer (SPSC) ring buffer for Rust, built directly on std::sync::atomic with zero dependencies.

RingBuffer<T> is a fixed-capacity circular queue that lets exactly one producer thread call push and exactly one consumer thread call pop concurrently — no Mutex, no locking, no allocation after construction, and no busy-waiting inside the data structure itself (the caller decides whether/how to retry on Err/None).

Why this is useful

SPSC ring buffers are one of the most common building blocks in real, production, latency-sensitive systems, because a lock-free queue between exactly two threads is both the easiest case to make provably correct and the case that shows up constantly in practice:

  • Audio: real-time audio callbacks (e.g. feeding a sound card driver) cannot take a lock or allocate without risking glitches/xruns — an SPSC ring buffer is the standard way to hand samples from a decoder/mixer thread to the audio callback thread.
  • Networking: packet capture and network I/O pipelines use SPSC rings to move packets from an interrupt/poll thread to a processing thread without lock contention on the hot path.
  • Databases / storage engines / VCS-style tooling: write-ahead-log shippers, replication pipelines, and content-addressed transfer protocols (the kind of decoupled producer/consumer pipeline used in systems like Git's pack negotiation or IPFS's Bitswap) all lean on bounded queues to let a fast producer and a slower (or bursty) consumer run independently without blocking each other on a mutex.
  • Game engines: passing frame/input/event data between the simulation thread and the render thread is a textbook SPSC handoff.

This crate implements that data structure from first principles with AtomicUsize head/tail indices and explicit Acquire/Release memory ordering, so it's a real (if small) example of the pattern rather than a wrapper around a lock.

Usage

use rs_ringbuffer::RingBuffer;
use std::sync::Arc;
use std::thread;

let rb = Arc::new(RingBuffer::new(1024));

let producer = {
    let rb = Arc::clone(&rb);
    thread::spawn(move || {
        for i in 0..10_000 {
            let mut item = i;
            while let Err(back) = rb.push(item) {
                item = back; // buffer full, retry
                thread::yield_now();
            }
        }
    })
};

let consumer = {
    let rb = Arc::clone(&rb);
    thread::spawn(move || {
        let mut received = Vec::with_capacity(10_000);
        while received.len() < 10_000 {
            match rb.pop() {
                Some(v) => received.push(v),
                None => thread::yield_now(), // buffer empty, retry
            }
        }
        received
    })
};

producer.join().unwrap();
let received = consumer.join().unwrap();
assert_eq!(received, (0..10_000).collect::<Vec<_>>());

Add it to your Cargo.toml:

[dependencies]
rs-ringbuffer = "1.0"

How it works

RingBuffer<T> stores its elements in a fixed-size Box<[UnsafeCell<MaybeUninit<T>>]> and tracks two monotonically increasing AtomicUsize counters:

  • tail — the next slot index the producer will write to. Only ever written by the producer.
  • head — the next slot index the consumer will read from. Only ever written by the consumer.

The real slot in the backing array is index % capacity; tail and head themselves just keep counting up (they don't wrap), which sidesteps any ambiguity between "empty" and "full" that naive wrapped-index ring buffers run into.

push loads tail (Relaxed — it's the producer's own variable, so ordinary program order already makes this coherent) and head (Acquire). If tail - head == capacity, the buffer is full and push returns Err(item). Otherwise it writes item into slot tail % capacity and then stores tail + 1 with Release.

pop loads head (Relaxed, same reasoning) and tail (Acquire). If head == tail, the buffer is empty and pop returns None. Otherwise it reads the value out of slot head % capacity and stores head + 1 with Release.

The soundness argument for the unsafe slot access on both sides comes down to one invariant, maintained by construction:

  • The producer only ever writes to slot tail % capacity, and only advances tail (publishing that write with Release) after the write is done.
  • The consumer only ever reads from slot head % capacity, and only after its Acquire load of tail shows that slot has actually been published by the producer.
  • Symmetrically, the producer will not reuse (overwrite) slot tail % capacity until its Acquire load of head shows the consumer has already finished reading that slot and published the read with its own Release store.

Because of that Release/Acquire pairing in both directions, the producer and consumer are never touching the same slot at the same time — the producer only writes a slot that's either brand new or has already been vacated by the consumer, and the consumer only reads a slot that's already been fully published by the producer and won't be touched again until it says so. That's exactly the condition needed for the raw pointer dereferences inside push/pop to be free of data races despite going through a shared &self. The full, method-by-method version of this argument is written out as doc comments directly on RingBuffer::push and RingBuffer::pop in src/lib.rs.

Drop for RingBuffer<T> takes &mut self, so at that point there is provably no concurrent access happening; it walks the live head..tail range and drops exactly the elements that were pushed but never popped, exactly once each — no leaks, no double-drops.

Invariant callers must uphold: at most one thread may call push at a time, and at most one thread may call pop at a time (a single thread may call both). This isn't enforced by the type system — much like std::sync::mpsc's Sender, it's a documented contract of the API, not a compile-time guarantee.

Testing

cargo test

This runs:

  • Basic unit tests for push/pop ordering, empty/full edge cases (Result/Option correctness, including that a failed push hands the item back unchanged), wraparound behavior across many laps, a zero-capacity panic check, and a drop-correctness test using a drop-counting guard type.
  • A genuine concurrency stress test (spsc_concurrent_stress): a real producer std::thread and a real consumer std::thread, transferring 200,000 items through a buffer of capacity 64 (i.e. the buffer is ~3,000x smaller than the item count, so both sides spend real time contending — push returning Err and pop returning None constantly), each busy-retrying with thread::yield_now() on failure. The test asserts every single item was received exactly once, in the exact order it was pushed, and repeats the whole thing 8 times in a loop to increase the odds of catching a race if one existed.

Miri

cargo miri test is the recommended deeper check for the unsafe code in this crate — Miri interprets the test suite and can catch undefined behavior (data races, reads of uninitialized memory, violations of Rust's aliasing/UnsafeCell rules) that a normal cargo test run cannot detect, since a normal run only checks the values that come out, not whether the memory operations that produced them were UB.

Miri was not run for this release: the miri component isn't available for the stable-x86_64-pc-windows-gnu toolchain used to build this crate on this machine (rustup component add miri fails with "the 'miri' component ... is not available for the 'stable-x86_64-pc-windows-gnu' toolchain" — Miri ships only for a subset of toolchains/host triples, and requires +nightly in addition). If you have a toolchain where Miri is available (e.g. nightly on a supported host), running cargo +nightly miri test is worthwhile before relying on this crate in a context where UB would be costly.

License

MIT © 2026 kasapdev — see LICENSE.

About

A lock-free single-producer/single-consumer ring buffer built on atomics. Zero-dependency Rust.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages