Ring Buffers
Learn how to bend a contiguous array into an infinite circle to build fast FIFO data pipelines.
In the , we explored how decoupling producers and consumers allows data to flow efficiently through our applications. We looked at standard library tools like std::queue and saw how they act as adapters over underlying containers like std::deque.
However, we also exposed the physical hardware limitations of these standard containers. Arrays like std::vector force catastrophic memory shifts when pulling data from the front. Chunked arrays like std::deque solve the shifting problem, but still require dynamic allocations when chunks fill up.
The Ring Buffer Concept
We can solve this problem by changing our perspective. Instead of deleting things and moving data through the queue, we can keep the queue relatively static and instead move pointers over the data.
We start by pre-allocating a single, contiguous array. We will never resize this array, guaranteeing zero dynamic allocations during runtime.
We then establish two integer indices: a Head and a Tail. The Tail represents where we write new data. The Head represents where we read existing data.
When the program starts, both the Head and the Tail sit at index 0. When a producer wants to push data to the queue, it writes the payload into the array at the Tail index, and then increments the Tail by 1.
When a consumer wants to pop the next element from the queue, it looks at the Head index. It reads the payload, processes it, and then increments the Head by 1.
This achieves reads and writes, first-in-first-out ordering, zero allocations, and stable data positioning. But what happens when we reach the end of our array and run out of space to write?
Bending the Array
If our array has a capacity of 8, and our Tail index reaches 8, we are out of space. We cannot write any more data, even if the consumer has already processed the data at the indices 0, 1, and 2.
The solution is to bend the array into an infinite circle.
When the Tail or the Head increments past the physical boundary of the array, we snap the index back to 0. The pointers simply wrap around and begin overwriting the stale, already-consumed memory at the start of the array.
Because the pointers endlessly chase each other in a circle, the data structure is capable of moving an infinite amount of streaming data through a small, fixed-size physical footprint.
In this design, we also keep at least one slot free at all times. This means the Tail cannot "catch up" to the Head, except when the buffer is empty.
We'll explain why this is important later in the lesson, but the immediate implication is that it reduces our capacity by 1. For example, if we were to use an 8-slot array, our buffer could have up to 7 items waiting to be processed.
The Arithmetic of Wrapping
To implement this wrap-around behavior, we apply mathematical bounds to our indices every time we increment them. The standard, textbook way to implement wrap-around behavior is to use the modulo operator (%).
The modulo operator divides the left operand by the right operand and returns the remainder. If our Capacity is 8, using i % 8 whilst incrementing i gives us the exact wrap-around pattern we want:
// Capacity = 8
// Incrementing normally
0 % 8 // 0
1 % 8 // 1
// ...
6 % 8 // 6
7 % 8 // 7
// The Wrap-Around
8 % 8 // 0
9 % 8 // 1However, the modulo operator is implemented in silicon using integer division instructions. Integer division is one of the most expensive fundamental operations the Arithmetic Logic Unit (ALU) can perform.
If we are pushing 10 million network packets per second through our buffer, forcing the ALU to perform 10 million division operations simply to wrap an index is an excessive waste.
The Bitwise Mask
We can eliminate this division penalty by using faster . To make this work, we first need to enforce a structural rule: the capacity of our ring buffer must always be a power of 2 (e.g., 8, 16, 256, 1024). As we saw previously, powers of 2 have a useful binary property - they have only a single bit set to 1.
For example, if our capacity is 8, its binary representation is 00001000. If we subtract 1 from this capacity, we get 7, which has the binary representation 00000111. Notice that subtracting 1 from 8 has perfectly flipped all the bits to the right from 0 to 1.
This is true for any power of two. If we set the capacity to 16 (00010000) and subtract 1, we get 15 (00001111).
By using this Capacity - 1 value as a mask, the bitwise AND operator & replicates the same pattern as the modulo operator, but at a fraction of the cost:
// Capacity = 8 (00001000), Mask = 7 (00000111)
// Incrementing normally
0 & 7 // 000 & 111 = 000 (0)
1 & 7 // 001 & 111 = 001 (1)
// ...
6 & 7 // 110 & 111 = 110 (6)
7 & 7 // 111 & 111 = 111 (7)
// The Wrap-Around
8 & 7 // 1000 & 0111 = 0000 (0)
9 & 7 // 1001 & 0111 = 0001 (1)A bitwise AND executes in a single CPU cycle, whilst integer division generally requires at least 10, and even more for large types like size_t.
Building the Container
Let's begin implementing our RingBuffer. We will use a generic template parameter T for the payload type.
Because we require the capacity to be a power of 2 to support our bitwise mask optimization, we will use the std::has_single_bit function from the <bit> library to validate the user's requested capacity during construction:
dsa_core/include/dsa/RingBuffer.h
#pragma once
#include <vector>
#include <cstdint>
#include <bit>
#include <stdexcept>
template <typename T>
class RingBuffer {
private:
std::vector<T> Buffer;
size_t Capacity;
size_t Mask;
// Both pointers start at 0
size_t Head{0};
size_t Tail{0};
public:
RingBuffer(size_t RequestedCapacity) {
// Enforce the power-of-2 rule
if (!std::has_single_bit(RequestedCapacity)) {
throw std::invalid_argument(
"Capacity must be a power of 2"
);
}
Capacity = RequestedCapacity;
// Pre-calculate the bitmask
Mask = Capacity - 1;
// Pre-allocate the physical memory once
Buffer.resize(Capacity);
}
};As with all high-performance systems, we do the heavy lifting of allocating the std::vector exactly once in the constructor. The structure will never grow, and it will never trigger a reallocation spike.
Resolving Full and Empty States
Before we can implement Push() and Pop(), we need to solve an important logical puzzle of a ring buffer: how do we know if it is full or empty?
If the queue is empty, the Head and Tail point to the same index. The producer hasn't written anything new, and the consumer has caught up completely.
bool IsEmpty() const {
return Head == Tail;
}But what happens when the queue is full? Imagine a buffer with a capacity of 8. The Head is at 0. The producer writes 8 items, wrapping the Tail all the way around the circle until it lands back on 0.
Now, the Head and Tail are pointing to the same index again. Because both the "Empty" state and the "100% Full" state result in Head == Tail, we cannot determine if there are 0 items available to read or 8 items available to read.
The "Always One Open" Strategy
There are two main ways to solve this ambiguity. The first approach is to add a Size counter to our class. When we push, we Size++. When we pop, we Size--. We can then just ask if (Size == 0) to determine if the buffer is empty, or if (Size == Capacity) to determine if it's full.
While this works in a single-threaded environment, it introduces a severe bottleneck when multithreading. As we will see in the next lesson, if one CPU core is pushing and another CPU core is popping, forcing both cores to constantly update the same Size integer creates massive cache-line contention, crippling our throughput.
The second approach is to deliberately sacrifice exactly one slot of physical memory. We do this by establishing a simple rule: the Tail is never allowed to catch up to the Head. If advancing the Tail by 1 would cause it to collide with the Head, we declare the buffer "Full" and refuse the write.
bool IsFull() const {
// Check if the next theoretical step of the Tail
// (wrapped by our mask) hits the Head
return ((Tail + 1) & Mask) == Head;
}By sacrificing a single payload slot, we eliminate the ambiguity without the performance cost and thread contention of maintaining a Size variable. The Head == Tail state will always mean the buffer is empty.
We can still provide a GetSize() function if doing so would be useful in its own right. We would use arithmetic to track how far the Head is from the Tail, remembering that the tail may have wrapped around to be physically behind the head:
size_t GetSize() const {
if (Tail >= Head) {
// The Tail is physically ahead of (or equal to) the Head
return Tail - Head;
} else {
// The Tail has wrapped around and is behind the Head
return Capacity - (Head - Tail);
}
}Implementing Push()
With our state checks in place, let's implement the core logic.
When pushing, we ensure the buffer is not full, write the payload to the Tail index, and increment the Tail using our bitmask. We return true if the push was successful, or false if it failed because the buffer is full:
dsa_core/include/dsa/RingBuffer.h
// ...
template <typename T>
class RingBuffer {
// ...
public:
// ...
bool IsEmpty() const {
return Head == Tail;
}
bool IsFull() const {
return ((Tail + 1) & Mask) == Head;
}
bool Push(const T& Value) {
// Reject the write if the buffer is full
if (IsFull()) {
return false;
}
// Write the data to the current tail slot
Buffer[Tail] = Value;
// Advance the tail and wrap it
Tail = (Tail + 1) & Mask;
return true;
}
};Implementing Pop()
When popping, we ensure the buffer is not empty, read the payload from the Head index, and increment the Head using our bitmask. This function needs to provide two values to its caller - a boolean representing whether the read was successful and a T - the value itself.
We could define a new struct for this return value, or use a templated result type like std::expected which we cover in
However, to keep things simple, we'll return a bool and ask users to provide a memory address (a T&) of a variable that we will update if our function returns true:
dsa_core/include/dsa/RingBuffer.h
// ...
template <typename T>
class RingBuffer {
// ...
public:
// ...
bool Pop(T& OutValue) {
// Reject the read if the buffer is empty
if (IsEmpty()) {
return false;
}
// Extract the data from the current head slot
OutValue = Buffer[Head];
// Advance the head and wrap it
Head = (Head + 1) & Mask;
return true;
}
};Usage Example
Below, we have split our workload across two functions - one for producing audio samples and one for consuming samples, with our RingBuffer between them.
The complex control flow around Push() and Pop() in this simple example is not strictly required, but it demonstrates a common pattern when working with queues in the real world:
- The queue might be full, so the producer keeps calling
Push()until a consumer (running in another thread) creates some space by removing and processing one of the existing samples. - The queue might be empty, so the consumer keeps calling
Pop()until a producer (running in another thread) adds something to the buffer for it to work on.
dsa_app/main.cpp
#include <print> // C++23
#include <thread>
#include <dsa/RingBuffer.h>
int main() {
RingBuffer<int> AudioStream(1024);
auto audio_producer = [&]() {
// Produce and push 5 samples
for (int i = 1; i <= 5; ++i) {
std::println("Producing sample {}", i);
// Keep trying to push the sample until the buffer
// accepts it - that is, until Push returns true)
while (!AudioStream.Push(i)) {
// Push returned false - buffer is
// currently full - try again
}
}
};
auto audio_consumer = [&]() {
int sample;
int count = 0;
// Keep iterating until the buffer provides 5 samples
// - that is, until Pop returns true 5 times
while (count < 5) {
if (AudioStream.Pop(sample)) {
// Pop returned true - process the sample
std::println("Playing sample {}", sample);
count++;
} else {
// Pop returned false - buffer is currently
// empty - try again
}
}
};
audio_producer();
audio_consumer();
return 0;
}Producing sample 1
Producing sample 2
Producing sample 3
Producing sample 4
Producing sample 5
Playing sample 1
Playing sample 2
Playing sample 3
Playing sample 4
Playing sample 5This is sequential for now - we produce all the samples, and then we consume them. But in the next lesson, we'll update our RingBuffer to a thread-safe, lock-free container. This means we can direct two cores at the problem, letting us produce and consume samples concurrently.
Benchmarking
We now have a continuous data pipeline that operates with efficiency, uses no locks, triggers no allocations, and mathematically wraps its pointers in a single CPU cycle. Let's benchmark our circular queue against the standard library's std::queue, which relies on chunked heap allocations.
We will execute a loop that pushes 1,000 elements into each queue, and then pops them back out:
benchmarks/main.cpp
#include <benchmark/benchmark.h>
#include <queue>
#include <dsa/RingBuffer.h>
static void BM_StdQueue_Pipeline(benchmark::State& state) {
std::queue<int> pipeline;
for (auto _ : state) {
// Push 1000 items
for (int i = 0; i < 1000; ++i) {
pipeline.push(i);
}
// Pop 1000 items
for (int i = 0; i < 1000; ++i) {
int val = pipeline.front();
pipeline.pop();
benchmark::DoNotOptimize(val);
}
}
}
BENCHMARK(BM_StdQueue_Pipeline);
static void BM_RingBuffer_Pipeline(benchmark::State& state) {
// Pre-allocate a power-of-2 buffer large enough for our burst
RingBuffer<int> pipeline(1024);
for (auto _ : state) {
// Push 1000 items
for (int i = 0; i < 1000; ++i) {
pipeline.Push(i);
}
// Pop 1000 items
for (int i = 0; i < 1000; ++i) {
int val;
pipeline.Pop(val);
benchmark::DoNotOptimize(val);
}
}
}
BENCHMARK(BM_RingBuffer_Pipeline);--------------------------------
Benchmark CPU
--------------------------------
BM_StdQueue_Pipeline 3223 ns
BM_RingBuffer_Pipeline 1765 nsThe std::deque underpinning the std::queue suffers from the overhead of managing its internal memory chunks, checking chunk boundaries, and sporadically calling the OS allocator when it needs more memory.
Our RingBuffer executes purely via index manipulation within a pre-allocated slab. It operates around 2 times faster than std::queue in this scenario, with zero risk of latency spikes caused by the global heap.
Advanced: Batch Processing and Memory Copies
Our Push() and Pop() methods are efficient for moving single elements. But in the real world, data rarely arrives one integer at a time. If we are writing an audio driver, the operating system does not hand us one audio sample; it hands us an array of 512 samples. If we receive a UDP network packet, it contains 1,400 bytes of data.
We could loop over these arrays and call Push() 512 times. However, doing so forces the CPU to evaluate the IsFull() boundary check and apply the bitwise mask 512 individual times.
When we are dealing with bulk data, the fastest way to move it is to use the raw memory copy instruction, std::memcpy. This hardware-accelerated instruction can move massive blocks of contiguous memory in a fraction of a microsecond.
To support this, we will write a PushSpan() method. It will accept a std::span, which is a lightweight C++20 view over a contiguous block of data. We'll first calculate how many elements we need to write. This count will either be the size of the input, or the amount of physical space our buffer has available - whichever is smaller:
dsa_core/include/dsa/RingBuffer.h
// ...
#include <cstring> // for std::memcpy
#include <span>
template <typename T>
class RingBuffer {
// ...
public:
// ...
// Calculate exactly how many items we can write
size_t AvailableWriteSpace() const {
if (Tail >= Head) {
return Capacity - (Tail - Head) - 1;
} else {
return (Head - Tail) - 1;
}
}
size_t PushSpan(std::span<const T> InputData) {
size_t WriteCount = std::min(
InputData.size(),
AvailableWriteSpace()
);
if (WriteCount == 0) return 0;
// Copying the data is done in the next section
}
};The Wrap-Around Split
The challenge with batch copying into a ring buffer is the physical boundary of the array.
If we want to write 10 items, but our Tail is sitting just 4 slots away from the end of the Capacity, we cannot execute a single std::memcpy of 10 items. The copy operation will spill out of our physical array and overwrite adjacent memory, instantly corrupting our application.
We must calculate how much contiguous physical space is remaining between the Tail and the end of the buffer.
If the contiguous space is large enough to hold the entire batch, we perform a single memcpy. If the batch is too large, we must split the write into two parts: one memcpy to fill the remaining space at the end of the array, and a second memcpy to wrap around and write the remaining data at the physical start of the array.
Let's complete our PushSpan() implementation by determining if we need to split the operation:
dsa_core/include/dsa/RingBuffer.h
// ...
template <typename T>
class RingBuffer {
// ...
public:
// ...
size_t PushSpan(std::span<const T> InputData) {
size_t WriteCount = std::min(
InputData.size(),
AvailableWriteSpace()
);
if (WriteCount == 0) return 0;
// Calculate how much physical space exists
// before the array ends
size_t SpaceUntilWrap = Capacity - Tail;
if (WriteCount <= SpaceUntilWrap) {
// Scenario A: The data fits entirely within
// the physical bounds. We perform one clean
// memory copy
std::memcpy(
&Buffer[Tail],
InputData.data(),
WriteCount * sizeof(T)
);
} else {
// Scenario B: The data crosses the wrap boundary
// Fill the remaining space at the end of the buffer
std::memcpy(
&Buffer[Tail],
InputData.data(),
SpaceUntilWrap * sizeof(T)
);
// Wrap around and write the leftover data at the start
size_t Leftover = WriteCount - SpaceUntilWrap;
std::memcpy(
&Buffer[0],
InputData.data() + SpaceUntilWrap,
Leftover * sizeof(T)
);
}
// Update the Tail in a single operation
Tail = (Tail + WriteCount) & Mask;
return WriteCount;
}
};By batching our writes, the CPU prefetcher is fully engaged, and we bypass the loop overhead. This technique allows network buffers to swallow massive Ethernet frames essentially instantaneously.
We can implement a highly similar PopSpan() method to allow consumers to extract bulk data with the same dual-memcpy strategy. This is included in the Complete Code section below.
Complete Code
Here is the complete implementation of our high-performance, contiguous RingBuffer, including the bulk-processing PopSpan logic:
Files
Summary
In this lesson, we solved the physical bottlenecks of traditional queues by completely eliminating data movement.
- We bent a contiguous array into a circle to create a ring buffer, achieving zero-allocation, stream processing where the data sits still and the pointers move.
- We avoided the heavy CPU penalty of modulo division by enforcing a power-of-2 capacity, unlocking the ultra-fast bitwise mask for instantaneous index wrapping.
- We solved the
Head == Tailfull/empty ambiguity by deliberately sacrificing one memory slot, eliminating the need for a highly contendedSizecounter. - We implemented batch processing via
memcpy, splitting our writes across the physical array bounds to achieve maximum hardware throughput.
By removing the centralized Size counter, we have established a mechanical design where the producer thread only ever modifies the Tail index, and the consumer thread only ever modifies the Head index.
This separation is extremely helpful when we need to implement lock-free multithreading, which we will do in the next lesson by upgrading our buffer to support SPSC (Single-Producer Single-Consumer) concurrency.
SPSC Lock-Free Queues
Build a Single-Producer Single-Consumer lock-free ring buffer using atomics, memory barriers, and cache-line alignment.