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).
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.
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"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 advancestail(publishing that write withRelease) after the write is done. - The consumer only ever reads from slot
head % capacity, and only after itsAcquireload oftailshows that slot has actually been published by the producer. - Symmetrically, the producer will not reuse (overwrite) slot
tail % capacityuntil itsAcquireload ofheadshows the consumer has already finished reading that slot and published the read with its ownReleasestore.
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.
cargo test
This runs:
- Basic unit tests for push/pop ordering, empty/full edge cases (
Result/Optioncorrectness, including that a failedpushhands 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 producerstd::threadand a real consumerstd::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 —pushreturningErrandpopreturningNoneconstantly), each busy-retrying withthread::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.
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.
MIT © 2026 kasapdev — see LICENSE.