Safe Systems Programming: Zero-Cost Ring Buffers in Rust
Zero-cost Promise
Zero-cost abstractions fundamentally change how we think about building systems. Rust’s zero-cost abstractions allow developers to write expressive, high-level code without compromising performance. This article will showcase zero-cost abstractions in Rusy by building a ring buffer for a reliable UDP network protocol.
Ring buffers are essential in systems programming, used in areas like audio processing and network packet handling. Despite their simplicity, creating a safe and efficient implementation is challenging.
Recall the requirements for a network buffer:
- Store network bytes within the same memory allocation
- Support single and batch inserts/removals
- Insertion length is unknown in advance
- Insertion is fallible
We’ll start with the basics of single-element operations and build up to a flexible method for inserting data of unknown length.
In future articles we will extend the current framework to implement an
UnorderedBuffer
that can effectively buffer UDP traffic.
Network Ring Buffers
Network buffers typically use a circular ring buffer. Ring buffers are fixed-size, circular, buffers with constant time addition and removal operations. Constant time performance without copying data and excellent data locality makes ring buffers suitable for data streaming workloads.
Our application will implement a buffer, Ring
, that is implemented using a head pointer and length:
pub struct Ring<'a, T> {
head: u32,
len: u32,
buffer: &'a mut [T],
}
Network buffers typically use a fixed length. The diagram below shows a 10-element buffer:
Packets with different lengths can be inserted at the head of the buffer:
Removing the first two packets does not cause the third packet to be re-arranged within the buffer:
Inserting a packet longer than available space in the buffer tail will cause it to wrap around. Wrapping causes the last element to be padded.
Removing the third element also removes the padding:
There are different ways to implement ring buffers, however, we choose the head and length for simplicity.
Zero-cost Abstractions
Recall that receiving a UDP packet requires a preallocated buffer with known location. The size of the packet is unknown ahead of time and the receive process is fallible.
let socket = std::net::UdpSocket::bind("127.0.0.1:34254")?;
// Packet will be truncated if `buf` is too small
let mut buf = [0; 10];
let (written, _) = socket.recv_from(&mut buf)?;
In this article we will implement basic single-element insertion and removal methods ending in an insertion method that can handle the unknown length case.
let mut ring: Ring<'_, u8>;
let mut socket: std::net::UdpSocket;
// Insert single element
*ring.push()?.data_mut() = 42;
// Remove single element and verify it's value
assert_eq!(*ring.pop()?.data(), 42);
// Insert message fallibly
let written = ring.push_many_with(|mut v: RingMutView<'_, '_, T>| {
let (written, _) = socket.recv_from(v.data_mut())?;
v.set_modified(written);
Ok(written)
})??;
Introducing Views
Views in our ring buffer provide a way to access and manipulate it’s elements safely and efficiently without copying data or taking ownership. They act as references to specific portions of the buffer.
For operations on individual elements we use two types of views:
RingView
: Provides read-only access to an elementRingMutView
: Provides read-write access to an element
Here is how we implement RingMutView
:
pub struct RingMutView<'a, 's, T> {
index: u32, // Position in the buffer
ring: &'a mut Ring<'s, T>, // Mutable reference to the ring buffer
}
impl<'a, 's, T> RingMutView<'a, 's, T> {
pub fn index(&self) -> u32 {
self.index
}
pub fn data(&self) -> &T {
// Returns an immutable reference to the element at the index
&self.ring.buffer[self.index as usize]
}
pub fn data_mut(&mut self) -> &mut T {
// Returns a mutable reference to the element at the index
&mut self.ring.buffer[self.index as usize]
}
}
The
'a
and's
are lifetimes, ensuring the references stay valid. Don’t worry too much about them for now—they’re Rust’s way of keeping things safe.
Let’s implement Ring::push
which pushes a single element into the buffer:
impl<'a, T> Ring<'a, T> {
/// Push a new element, returning a mutable view to write to it
pub fn push(&mut self) -> Result<RingMutView<'_, 'a, T>, ErrorFull> {
if self.is_full() {
return Err(ErrorFull);
}
let index = self.tail();
self.len += 1;
Ok(RingMutView::new(index, self))
}
}
Ring::push
gives us a RingMutView
using which we can write data to the buffer:
let mut ring: Ring<'_, u8>;
*ring.push()?.data_mut() = 42;
Now we can implement Ring::pop
which pops the value we just pushed off the head:
impl<'a, T> Ring<'a, T> {
/// Pop the oldest element, returning a view to read it
pub fn pop(&mut self) -> Option<RingView<'_, 'a, T>> {
if self.is_empty() {
return None;
}
let index = self.head();
self.head = self.get_idx(1);
self.len -= 1;
Some(RingView::new(index, self))
}
}
Ring::pop
gives us a RingView
to read the data back out:
let mut ring: Ring<'_, u8>;
assert_eq!(*ring.pop()?.data(), 42);
Views let us work with the data directly- no copies, no fuss.
Introducing Closures
Closures in Rust are anonymous functions that can capture their environment. When paired with views, they offer a powerful way to perform flexible, fallible operations on the ring buffer, especially when the data size is determined at runtime.
The Ring::push_many_with
method uses a closure and view, RingManyMutView
, to handle operations where the number of elements to insert isn’t known upfront.
Here’s how it works for receiving a UDP packet:
let mut socket: std::net::UdpSocket;
let written = ring.push_many_with(|mut v: RingManyMutView<'_, '_, u8>| {
let (written, _) = socket.recv_from(v.data_mut())?;
v.set_modified(written);
Ok(written)
})??;
Lets break this down.
Ring::push_many_with
takes a closure that receives a RingManyMutView
which represents a mutable slice of the ring buffer. RingManyMutView::set_modified
tells the ring buffer how many elements were actually modified.
pub struct RingManyMutView<'a, 's, T> {
modified: &'a mut u32,
view: RingManyView<'a, 's, T>,
}
impl<'a, 's, T> RingManyMutView<'a, 's, T> {
pub fn set_modified(&mut self, modified: u32) {
*self.modified = modified;
}
}
Ring::push_many_with
must return the largest available empty window to write to the buffer. After data has been written we need to shrink RingManyMutView
to the length of the data written:
Ring::push_many_with
has the following implementation:
impl<'a, T> Ring<'a, T> {
/// Push many elements into ring
///
/// # Panics
/// Panics if the write count returned by the closure was larger than the
/// available buffer window.
pub fn push_many_with<R, E, F>(&mut self, f: F) -> Result<R, E>
where
F: FnOnce(RingManyMutView<'_, 'a, T>) -> Result<R, E>,
{
let index = self.tail();
let len = self.empty_tail_window();
let view = RingManyView::new(index, len, self);
// `modified` can be overwritten by the calling function to shorten the committed buffer
let modified = &mut len.clone();
let res = f(RingManyMutView::new(modified, view));
if res.is_ok() {
if *modified > len {
panic!("written count was larger than available buffer window");
}
self.len += *modified;
}
res
}
}
Ring::empty_tail_window
returns the length of contiguous empty elements at the end of the buffer.The empty tail window is typically at the end of our buffer allocation, however, it may be at the beginning of the allocation after the ring buffer has wrapped around.
>
impl<'a, T> Ring<'a, T> { /// Returns the length of contiguous empty elements at the end of the buffer pub fn empty_tail_window(&self) -> u32 { core::cmp::min(self.window(), self.capacity() - self.tail()) } }
Our basic implementation does not guarantee that we will have enough space to fill in the window and also won’t automatically wrap the buffer around.
In future articles we will extend the current framework to implement an
UnorderedBuffer
that can effectively buffer UDP traffic.
Rust in Action
The integration of views and closures in this ring buffer exemplifies Rust’s zero-cost abstractions:
Efficiency: Views use references (&T
or &mut T
) for direct access to buffer elements, compiling down to simple pointer arithmetic with no runtime overhead. Closures, meanwhile, are inlined by the compiler, ensuring that high-level logic incurs no performance penalty compared to manual low-level code.
Safety: Rust’s borrow checker enforces proper use of views at compile time, preventing data races or invalid memory access without runtime checks.
Expressiveness: Closures allow developers to encapsulate custom logic (e.g., receiving network data) within a clean, reusable interface, while views simplify interactions with the buffer.
The result is a high-level API that feels intuitive yet performs as efficiently as a low-level implementation—exactly what zero-cost abstractions promise.
Conclusion
By combining views and closures, this Rust ring buffer implementation delivers a powerful solution for managing dynamic data streams, such as UDP packets. Views provide safe, efficient access to the buffer’s elements, while closures enable flexible, fallible operations for variable-sized data. Together, they leverage Rust’s zero-cost abstractions to offer a design that is simultaneously safe, simple, and fast.
This is Rust at its best: abstraction without compromise, performance without complexity.