Ah, circular buffers. A classic of consumer/producer designs: a fixed-size buffer that is effectively infinite as long as consumers keep up.
For all its simplicity, there are some important decisions to be made when the time comes to implement one (or better to select an existing one if it fits!).
Let's sketch out a description for the circular buffer as an ADT:
enqueue(B, e)
, adding an element, first-in first-out (FIFO)dequeue(B)
, removing and returning an element or indicating none is available, FIFOisFull(B)
, indicating whether the buffer is fullisEmpty(B)
, indicating whether the buffer is emptyAs a bonus, many of these decisions apply to other stream-like ADTs by the way, so this can serve as a handy guide to think through those (say, something like a network socket read/write). Streams also share the 'infinite sequence of elements' aspect of the abstraction.
A single-element circular buffer is useful if you have fixed-sized structures or if you're including pointers to data kept elsewhere (but then you have to figure out how to manage the lifetime of that data).
A more common approach is to have multi-element operations, which changes the ADT operations to these:
enqueue(B, elements)
, adding multiple elements, first-in first-out (FIFO)dequeue(B, maxElements)
, removing and returning up to maxElements
elements and indicating how many where removed, FIFOThis introduces some additional considerations for what happens when elements
is larger than all available data or buffer space, in which case the considerations for "Overwriting Behavior" below kick in. A common approach here for example is to preserve FIFO when overwriting, and to "wipe out" the buffer with the last buffer capacity
number of elements in the input elements
.
Often the circular buffer itself is rather "dumb" and manages mult-bytes reads and writes, and the framing for those bytes is given by a higher-level construct.
When someone writes more elements than the circular buffer has capacity for, there are a handful of strategies about what to do.
Partial commits for multi-element writes are very unusual in practice - I don't think I've ever seen an implementation that does that. That would be a case where you want to write 5 bytes but only have space for 3, so you write the first 3 and return an error.
You can run into a similar problem when someone asks to read more than is available.
The reader is always aware of how much valid data is available, so there usually isn't a "dirty read" scenario.
Another pivot for options in terms of concurrency is how many producers and consumers there are running at the same time.
If the circular buffer supports concurrency of some sort, the next aspect to consider is whether access is lock-based or lock-free.
There are a few ways in which I've seen concurrency managed in practice.
Windows has more than one audio port driver model; older ones relied on copying data around, then working with scatter/gather, and the latest one is the WaveRT Port Driver, based on a "cyclic buffer". (The term cyclic buffer refers to the fact that when the buffer position register reaches the end of the buffer in a playback or record operation, the position register can automatically wrap around to the beginning of the buffer.)
Here are how some of the choices work out in this example:
There are other related data structure like a bipartite buffer (bip buffer), which is basically a buffer with two regions, where element buffers are laid out contiguously (a "multi-element write" or read can be guaranteed to be contiguous, which is nice).
Happy buffering!