Jacob Labor

Head of Data Infrastructure

Wednesday, December 4th, 2024

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:

Empty ring buffer with start and tail pointers to its first element

Packets with different lengths can be inserted at the head of the buffer:

Ring buffer with single element at start pointer and tail pointer at the next empty index Ring buffer

Removing the first two packets does not cause the third packet to be re-arranged within the buffer:

Ring buffer with multiple elements at start pointer and tail pointer at the next empty index

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.

Ring buffer with first elements removed leaving a single element

Removing the third element also removes the padding:

Ring buffer with the last element padded to cover the remainder of the tail and a new element at the start of the buffer

There are different ways to implement ring buffers, however, we choose the head and length for simplicity.

I’ve been writing ring buffers wrong all these years

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 element
  • RingMutView: 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.

Ring buffer with RingMutBuffer pointing towards the first empty index

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 buffer with largest empty window exposed to RingManyMutView Ring buffer with empty window partially filled by write operation through RingManyMutView Ring buffer with trimmed RingManyMutView to the length of the inserted object

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.

Empty window at the end of the buffer > Empty tail window at the beginning of the buffer after buffer wrap

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.

Jacob Labor

Head of Data Infrastructure

linkedin

Join our Team

If you're at the pinnacle of your game and seek to collaborate with the elite, Anti Capital is your arena. Join us, where the best meet the best.

Recent Articles