Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 32

SPSC Ring Buffer: Power-of-Two Array Implementation

Recommended resources

Hello! Welcome to the first lesson of our fifth module.

In the previous module, we navigated the challenging landscape of low-level concurrency, culminating in a deep dive into the infamous ABA problem. We concluded that while it's a critical concept to understand, some data structures can be cleverly designed to sidestep it entirely. Today, we begin building one such structure: the Single-Producer, Single-Consumer (SPSC) ring buffer. This is the foundational component of your telemetry engine's data pipeline—the "spine" of the whole project.

This lesson focuses on the high-level design of the ring buffer. Our goal is to understand its core mechanics and the specific design choices that make it both simple and incredibly efficient. Specifically, you will learn to design an SPSC ring buffer that uses a power-of-two sized array, a key decision that unlocks significant performance benefits.

What is a Ring Buffer?

At its heart, a ring buffer (or circular buffer) is a fixed-size queue that wraps around on itself. When the end of the buffer is reached, it seamlessly continues from the beginning. This makes it an ideal data structure for streaming data between two entities, like threads in our case.

In a Single-Producer, Single-Consumer (SPSC) setup, we have:

  • One producer thread that exclusively adds new data to the buffer. It writes to the tail of the queue.
  • One consumer thread that exclusively removes data from the buffer. It reads from the head of the queue.
Ring Buffer Visualisation
This diagram illustrates the basic concept of a ring buffer. Data is inserted at the 'Tail' and extracted from the 'Head'. As data is added and removed, the head and tail pointers move around the circular array.

This strict one-to-one relationship is the key to its simplicity and performance. It allows us to build a "lock-free" and "wait-free" queue, meaning threads can communicate without ever having to wait for each other using expensive locks.

To understand why this is so valuable in high-performance systems, let's watch a short segment from a CppCon talk on this exact topic.

Single Producer Single Consumer Lock-free FIFO From the Ground Up - Charles Frasch - CppCon 2023

In this clip from 'Single Producer Single Consumer Lock-free FIFO From the Ground Up', Charles Frasch explains the motivation and common use cases for SPSC queues, such as building processing pipelines.

Watch from 02:18 to 06:21. Focus on understanding why breaking a complex task into a pipeline of threads connected by SPSC queues can improve throughput and resiliency.


The Naive Design and its Flaw

The most straightforward way to implement a ring buffer is with an array and two indices, a read index and a write index. Each time you add or remove an element, you increment the corresponding index and wrap it around using the modulo operator (e.g., index = (index + 1) % capacity).

This approach has a subtle but significant problem: how do you distinguish between a full buffer and an empty one?

  • Empty: The read and write indices are the same (read == write).
  • Full: If you fill the buffer completely, the write index wraps around and once again becomes equal to the read index.

The state read == write is ambiguous. The classic solution is to sacrifice one slot in the buffer. The buffer is considered full when the write index is one position behind the read index. This works, but it's slightly more complex and wastes memory—a small annoyance for large buffers, but a major issue for very small ones.

I've been writing ring buffers wrong all these years

The blog post 'I've been writing ring buffers wrong all these years' by Juho Snellman does an excellent job of explaining this classic problem.

Please read the first section, 'Array + two indices'. It provides a concise code-like example of this traditional implementation and clearly illustrates the ambiguity between the full and empty states.


A Better Design: Unbounded, Unmasked Indices

For high-performance applications, we can do much better. Instead of indices that wrap, we use indices that grow indefinitely.

Here's the design:

  1. Unbounded Indices: The head (read) and tail (write) indices are large unsigned integers (e.g., uint64_t). They start at 0 and are only ever incremented. They are never reset or wrapped with the modulo operator. They simply grow, eventually overflowing and wrapping around naturally (which is well-defined behavior for unsigned integers in C++).

  2. Power-of-Two Capacity: The capacity of the underlying array must be a power of two (e.g., 16, 1024, 65536).

This power-of-two constraint is the magic key. It allows us to map our unbounded head or tail index to an array index using a cheap bitwise AND operation instead of an expensive modulo (%) division.

The mapping is: array_index = unbounded_index & (capacity - 1).

For example, if capacity is 1024, then capacity - 1 is 1023, which in binary is 0b1111111111. Performing a bitwise AND with this mask is equivalent to taking the value modulo 1024, but it's orders of magnitude faster.

With this design, checking the buffer's state becomes beautifully simple:

  • Size: The number of items in the buffer is simply tail - head. Unsigned integer arithmetic handles the wraparound case correctly.
  • Empty: The buffer is empty when head == tail.
  • Full: The buffer is full when tail - head == capacity.

This design elegantly solves the ambiguity problem without wasting a slot and simultaneously replaces an expensive division with a single-cycle bitwise operation.

Let's watch another clip from the CppCon talk that visualizes this concept of ever-increasing cursors.

Single Producer Single Consumer Lock-free FIFO From the Ground Up - Charles Frasch - CppCon 2023

This section of the same video illustrates the concept of continuously increasing cursors and mentions the different ways to map them to array indices, including the efficient bitwise AND we just discussed.

Watch from 06:21 to 09:55. Pay close attention to the visual of the 'unrolled' buffer and the discussion of using 64-bit unsigned integers for the cursors.

Now, let's read the core of Juho Snellman's article, which details this superior approach.

I've been writing ring buffers wrong all these years

The article continues by introducing this very design and explaining its benefits and constraints.

Read the sections titled 'Array + two unmasked indices' and the list of restrictions that follows. This is the most critical part of the lesson and describes the exact design we will implement.

Test your understanding!

You are designing a ring buffer with a capacity of 32. The backing array has indices 0 through 31.

  1. What is the bitmask you would use to map an unbounded index to an array index?
  2. If the tail index is 35, what array slot will the next element be written to?
  3. If head is 40 and tail is 72, is the buffer full? How many elements are in it?
Show answer
  1. The bitmask is capacity - 1, which is 32 - 1 = 31. In binary, this is 0b11111.
  2. 35 & 31 (0b100011 & 0b011111) results in 3. The element will be written to array[3].
  3. The size is tail - head, which is 72 - 40 = 32. Since the size equals the capacity, the buffer is full. It contains 32 elements.

Outlining the C++ Structure

Based on this design, we can start to outline what our C++ class will look like. It will need to manage the buffer's memory and state. The core data members would be:

template <typename T>
class SPSCQueue {
private:
    const size_t capacity_;
    const size_t mask_;
    T* buffer_;

    // In the next lessons, these will become std::atomic
    // and will be aligned to prevent false sharing.
    size_t head_ = 0;
    size_t tail_ = 0;

public:
    SPSCQueue(size_t capacity)
        : capacity_(capacity), 
          mask_(capacity - 1),
          buffer_(new T[capacity]) 
    {
        // We must ensure capacity is a power of two!
        // An assertion here is a good idea.
    }

    ~SPSCQueue() {
        delete[] buffer_;
    }

    // Push/pop methods to be implemented...
};

This skeleton captures our design: it stores the capacity, pre-calculates the mask_, allocates the raw buffer_, and initializes the head_ and tail_ indices. The article "Optimizing a Ring Buffer for Throughput" (LINK) presents a similar structure but uses std::atomic and alignas for concurrency—features we will add in our next lessons. For now, this conceptual structure is all we need.


Conclusion

Today, you've learned the fundamental design principles of a high-performance SPSC ring buffer. We've moved from a naive, flawed implementation to a robust and efficient design tailored for concurrent systems programming.

Key Takeaways:

  • An SPSC ring buffer is a fixed-size, circular queue for one producer and one consumer, ideal for lock-free communication.
  • The classic design wastes a slot to distinguish between empty and full states.
  • A superior design uses unbounded, unmasked indices that continuously increment.
  • This design's efficiency hinges on a power-of-two capacity, which allows for a fast bitwise AND mask (index & (capacity - 1)) to map unbounded indices to array slots.
  • With this design, the buffer's state is unambiguous: empty when head == tail, and full when tail - head == capacity.

What's Next?

We have our blueprint. The next step is to make it thread-safe. In the upcoming lesson, "Use std::atomic indices for the head and tail pointers to enable concurrent access," we will replace our size_t indices with std::atomic<size_t>. We'll finally get to apply our knowledge of memory ordering from the previous module to ensure the producer and consumer can safely read and write to the buffer at the same time, without data races.