Circular/Ring Buffers
Brief:
- A circular buffer (also known as a ring buffer) is a fixed-size data structure that uses a
single, contiguous block of memory as if it were connected end-to-end. It maintains two pointers: a write
pointer(where new data is added) and a read pointer (where data is consumed). When either
pointer reaches the end of the buffer, it wraps around to the beginning.
- Since it uses a fixed-size buffer, there is no need to allocate or free memory dynamically. This helps
avoid memory fragmentation and keeps performance consistent.
- As the buffer fills up, new audio samples can overwrite the oldest samples, which is
ideal for real-time audio where recent data is often more relevant.
- Circular buffers are perfect for scenarios where one part of the system produces data
while another part consumes it. The circular nature ensures smooth
data flow, even if the producer and consumer operate at slightly different speeds.
Considerations:
- The producer and consumer are typically on different threads, and therefore read and writing to the buffer is thread-safe.
- A mutex can be utilized to protect the buffer from being concurrently modified.
- A condition variable can be utilized by a producer to notify a waiting consumer.
C++ Implementation
/// CircularBuffer.h
/**
Copyright © 2025 Alex Parisi
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#ifndef CIRCULAR_BUFFER_H
#define CIRCULAR_BUFFER_H
#include <array>
#include <atomic>
#include <mutex>
#include <optional>
#include <vector>
/**
* @brief A thread-safe circular buffer with a fixed size that supports
* concurrent push and pop operations. Thread-safety is achieved by using a
* mutex and condition variable. The producer (writing thread) will never be
* blocked, as the buffer will overwrite the oldest data if it is full. The
* consumer (reading thread) will wait for data if the buffer is empty, or does
* not contain enough requested samples.
* @tparam T the type of elements in the buffer
* @tparam blockSize the blockSize for array operations. If this is not defined,
* you can only use std::vector for block operations.
*/
template<typename T, size_t blockSize = 0>
class CircularBuffer {
public:
explicit CircularBuffer(const size_t capacity) : m_capacity(capacity) {
m_buffer.resize(capacity);
}
/**
* @brief Push a sample into the buffer
* @param value The sample to push
*/
auto push(const T &value) -> void {
std::unique_lock lock(m_mutex);
_push(value);
m_cv.notify_one();
}
/**
* @brief Push multiple samples into the buffer
* @param values Pointer to the array of samples
* @param count Number of samples to push
*/
auto push(const T *values, const size_t count) -> void {
if (values == nullptr)
return;
std::unique_lock lock(m_mutex);
for (size_t i = 0; i < count; ++i) {
_push(values[i]);
}
m_cv.notify_one();
}
/**
* @brief Push a vector of samples into the buffer
* @param values The vector of samples to push
*/
auto push(const std::vector<T> &values) -> void {
if (values.empty())
return;
std::unique_lock lock(m_mutex);
for (const auto &value: values) {
_push(value);
}
m_cv.notify_one();
}
/**
* @brief Push an array of samples into the buffer. Requires the blockSize
* to be defined when templating.
* @param values The array of samples to push
*/
auto push(const std::array<T, blockSize> &values) -> void {
if (blockSize <= 0) {
return;
}
std::unique_lock lock(m_mutex);
for (size_t i = 0; i < blockSize; ++i) {
_push(values[i]);
}
m_cv.notify_one();
}
/**
* @brief Pop a sample from the buffer
* @return The sample that was popped
*/
auto pop() -> T {
std::unique_lock lock(m_mutex);
/// Wait until there's at least one sample
m_cv.wait(lock, [this]() -> bool { return m_size > 0; });
return _pop();
}
/**
* @brief Pop samples from the buffer directly into an array or pointer.
* @param values Pointer to the array to store the samples
* @param count The number of samples to pop
* @return
*/
auto pop(T *values, const size_t count) {
if (count <= 0) {
return;
}
std::unique_lock lock(m_mutex);
/// Wait until there's enough samples
m_cv.wait(lock, [this, count]() -> bool { return m_size >= count; });
for (size_t i = 0; i < count; ++i) {
values[i] = _pop();
}
}
/**
* @brief Pop multiple samples from the buffer
* @param count The number of samples to pop
* @return A vector of samples that were popped
*/
auto pop(const size_t count) -> std::vector<T> {
if (count <= 0)
return {};
std::unique_lock lock(m_mutex);
/// Wait until there's enough samples
m_cv.wait(lock, [this, count]() -> bool { return m_size >= count; });
std::vector<T> values(count);
for (size_t i = 0; i < count; ++i) {
values[i] = _pop();
}
return values;
}
/**
* @brief Pop a block of samples from the buffer. Requires the blockSize to
* be defined when templating.
* @return An array of samples that were popped
*/
auto pop_block() -> std::array<T, blockSize> {
if (blockSize <= 0) {
return {};
}
std::unique_lock lock(m_mutex);
/// Wait until there's enough samples
m_cv.wait(lock, [this]() -> bool { return m_size >= blockSize; });
std::array<T, blockSize> values;
for (size_t i = 0; i < blockSize; ++i) {
values[i] = _pop();
}
return values;
}
/**
* @brief Clears the buffer
*/
auto clear() -> void {
std::unique_lock lock(m_mutex);
m_head = 0;
m_tail = 0;
m_size = 0;
m_buffer.clear();
m_buffer.resize(m_capacity);
}
/**
* @brief Get the size of the buffer
* @return The number of samples currently stored in the buffer
*/
[[nodiscard]] auto size() -> size_t { return m_size; }
/**
* @brief Check if the buffer is empty
* @return True if the buffer is empty, false otherwise
*/
[[nodiscard]] auto empty() const -> bool { return m_size == 0; }
/**
* @brief Check if the buffer is full
* @return True if the buffer is full, false otherwise
*/
[[nodiscard]] auto full() const -> bool { return m_size == m_capacity; }
/**
* @brief Get the capacity of the buffer
* @return The maximum number of samples the buffer can hold
*/
[[nodiscard]] auto capacity() const -> size_t { return m_capacity; }
private:
/**
* @brief Helper function to push a value into the buffer
* @param value The value to push
*/
auto _push(const T &value) -> void {
m_buffer[m_head] = value;
m_head = (m_head + 1) % m_capacity;
/// Update the current size, ensuring it doesn't exceed the buffer size
/// if we've overwritten old data. If our buffer is full, make sure the
/// head and tail pointers are the same.
m_size = std::min(m_size + 1, m_capacity);
if (m_size == m_capacity) {
m_tail = m_head;
}
}
/**
* @brief Helper function to pop a value from the buffer
* @return The value that was popped
*/
auto _pop() -> T {
T value = std::move(m_buffer[m_tail].value());
m_buffer[m_tail].reset();
m_tail = (m_tail + 1) % m_capacity;
--m_size;
return value;
}
/** The buffer */
std::vector<std::optional<T>> m_buffer;
/** The head of the buffer */
size_t m_head = 0;
/** The tail of the buffer */
size_t m_tail = 0;
/** The number of samples currently stored in the buffer */
std::atomic<size_t> m_size = 0;
/** The maximum number of samples the buffer can hold */
size_t m_capacity = 0;
/** The mutex used to protect the buffer */
std::mutex m_mutex;
/** The condition variable used to notify waiting consumer threads */
std::condition_variable m_cv;
};
#endif // CIRCULAR_BUFFER_H
Notes:
m_size
is an atomic to allow for some of the house-keeping functions like size()
and empty()
to not require locking the mutex.
- The class is templated with the type of the object stored in the buffer and the buffer size for block operations:
/// Block size = 512, Total buffer size = 2048
CircularBuffer<float, 512> buffer(2048);
/// Buffer gets filled with data
std::array<float, 512> block = buffer.pop_block();
- If the block size is not provided, the only supported block operations involve raw pointers and heap-allocated containers like
std::vector
:/// Block size = 0, Total buffer size = 512
CircularBuffer<double> buffer(512);
/// Buffer gets filled with data
auto block = buffer.pop_block(); /// block == {}
std::vector<double> block = buffer.pop(10); /// block.size() == 10
int size = 10;
double* array[size];
buffer.pop(array, size); /// array now contains the popped data
- The buffer utilizes std::optional to allow for proper resetting of the object state after moving, which is useful when
T
is templated to be a complex class.
- Data is moved from the buffer to the consumer, avoiding copies.