Circular buffers are based on an algorithm well known by any developer who’s got past the “Hello world!” days. They offer a number of key characteristics with wide applicability such as constant and efficient memory use, efficient FIFO semantics, etc.
One feature which is not always desired, though, it the fact that circular buffers traditionally will either overwrite the last element, or raise an overflow error, since they are generally implemented as a buffer of constant size. This is an unwanted property when one is attempting to consume items from the buffer and it is not an option to blindly drop items, for instance.
This post presents an efficient (and potentially novel) algorithm for implementing circular buffers which preserves most of the key aspects of the traditional version, while also supporting dynamic expansion when the buffer would otherwise have its oldest entry overwritten. It’s not clear if the described approach is novel or not (most of my novel ideas seem to have been written down 40 years ago), so I’ll publish it below and let you decide.
Traditional circular buffers
Before introducing the variant which can actually expand during use, let’s go through a quick review on traditional circular buffers, so that we can then reuse the nomenclature when extending the concept. All the snippets provided in this post are written in Python, as a better alternative to pseudo-code, but the concepts are naturally portable to any other language.
So, the most basic circular buffer needs the buffer itself, its total capacity, and a position where the next write should occur. The following snippet demonstrates the concept in practice:
buf = [None, None, None, None, None] bufcap = len(buf) pushi = 0 for elem in range(7): buf[pushi] = elem pushi = (pushi + 1) % bufcap print buf # => [5, 6, 2, 3, 4]
In the example above, the first two elements of the series (0 and 1) were overwritten once the pointer wrapped around. That’s the specific feature of circular buffers which the proposal in this post will offer an alternative for.
The snippet below provides a full implementation of the traditional approach, this time including both the pushing and popping logic, and raising an error when an overflow or underflow would occur. Please note that these snippets are not necessarily idiomatic Python. The intention is to highlight the algorithm itself.
class CircBuf(object): def __init__(self): self.buf = [None, None, None, None, None] self.buflen = self.pushi = self.popi = 0 self.bufcap = len(self.buf) def push(self, x): assert self.buflen == 0 or self.pushi != self.popi, "Buffer overflow!" self.buf[self.pushi] = x self.pushi = (self.pushi + 1) % self.bufcap self.buflen += 1 def pop(self): assert self.buflen != 0, "Buffer underflow!" x = self.buf[self.popi] self.buf[self.popi] = None self.buflen -= 1 self.popi = (self.popi + 1) % self.bufcap return x
With the basics covered, let’s look at how to extend this algorithm to support dynamic expansion in case of overflows.
Dynamically expanding a circular buffer
The approach consists in imagining that the same buffer can contain both a circular buffer area (referred to as the ring area from here on), and an overflow area, and that it is possible to transform a mixed buffer back into a pure circular buffer again. To clarify what this means, some examples are presented below. The full algorithm will be presented afterwards.
First, imagine that we have an empty buffer with a capacity of 5 elements as per the snippet above, and then the following operations take place:
for i in range(5): circbuf.push(i) circbuf.pop() # => 0 circbuf.pop() # => 1 circbuf.push(5) circbuf.push(6) print circbuf.buf # => [5, 6, 2, 3, 4]
At this point we have a full buffer, and with the original implementation an additional push would raise an assertion error. To implement expansion, the algorithm will be changed so that those items will be appended at the end of the buffer. Following the example, pushing two additional elements would behave the following way:
circbuf.push(7) circbuf.push(8) print circbuf.buf # => [5, 6, 2, 3, 4, 7, 8]
In that example, elements 7 and 8 are part of the overflow area, and the ring area remains with the same capacity and length of the original buffer. Let’s perform a few additional operations to see how it would behave when items are popped and pushed while the buffer is split:
circbuf.pop() # => 2 circbuf.pop() # => 3 circbuf.push(9) print circbuf.buf # => [5, 6, None, None, 4, 7, 8, 9]
In this case, even though there are two free slots available in the ring area, the last item pushed was still appended at the overflow area. That’s necessary to preserve the FIFO semantics of the circular buffer, and means that the buffer may expand more than strictly necessary given the space available. In most cases this should be a reasonable trade off, and should stop happening once the circular buffer size stabilizes to reflect the production vs. consumption pressure (if you have a producer which constantly operates faster than a consumer, though, please look at the literature for plenty of advice on the problem).
The remaining interesting step in that sequence of events is the moment when the ring area capacity is expanded to cover the full allocated buffer again, with the previous overflow area being integrated into the ring area. This will happen when the content of the previous partial ring area is fully consumed, as shown below:
circbuf.pop() # => 4 circbuf.pop() # => 5 circbuf.pop() # => 6 circbuf.push(10) print circbuf.buf # => [10, None, None, None, None, 7, 8, 9]
At this point, the whole buffer contains just a ring area and the overflow area is again empty, which means it becomes a traditional circular buffer.
With some simple modifications in the traditional implementation presented previously, the above semantics may be easily supported. Note how the additional properties did not introduce significant overhead. Of course, this version will incur in additional memory allocation to support the buffer expansion, bu that’s inherent to the problem being solved.
class ExpandingCircBuf(object): def __init__(self): self.buf = [None, None, None, None, None] self.buflen = self.ringlen = self.pushi = self.popi = 0 self.bufcap = self.ringcap = len(self.buf) def push(self, x): if self.ringlen == self.ringcap or self.ringcap != self.bufcap: self.buf.append(x) self.buflen += 1 self.bufcap += 1 if self.pushi == 0: # Optimization. self.ringlen = self.buflen self.ringcap = self.bufcap else: self.buf[self.pushi] = x self.pushi = (self.pushi + 1) % self.ringcap self.buflen += 1 self.ringlen += 1 def pop(self): assert self.buflen != 0, "Buffer underflow!" x = self.buf[self.popi] self.buf[self.popi] = None self.buflen -= 1 self.ringlen -= 1 if self.ringlen == 0 and self.buflen != 0: self.popi = self.ringcap self.pushi = 0 self.ringlen = self.buflen self.ringcap = self.bufcap else: self.popi = (self.popi + 1) % self.ringcap return x
Note that the above algorithm will allocate each element in the list individually, but in sensible situations it may be better to allocate additional space for the overflow area in advance, to avoid potentially frequent reallocation. In a situation when the rate of consumption of elements is about the same as the rate of production, for instance, there are advantages in doubling the amount of allocated memory per expansion. Given the way in which the algorithm works, the previous ring area will be exhausted before the mixed buffer becomes circular again, so with a constant rate of production and an equivalent consumption it will effectively have its size doubled on expansion.
UPDATE: Below is shown a version of the same algorithm which not only allows allocating more than one additional slot at a time during expansion, but also incorporates it in the overflow area immediately so that the allocated space is used optimally.
class ExpandingCircBuf2(object): def __init__(self): self.buf =  self.buflen = self.ringlen = self.pushi = self.popi = 0 self.bufcap = self.ringcap = len(self.buf) def push(self, x): if self.ringcap != self.bufcap: expandbuf = (self.pushi == 0) expandring = False elif self.ringcap == self.ringlen: expandbuf = True expandring = (self.pushi == 0) else: expandbuf = False expandring = False if expandbuf: self.pushi = self.bufcap expansion = [None, None, None] self.buf.extend(expansion) self.bufcap += len(expansion) if expandring: self.ringcap = self.bufcap self.buf[self.pushi] = x self.buflen += 1 if self.pushi < self.ringcap: self.ringlen += 1 self.pushi = (self.pushi + 1) % self.bufcap def pop(self): assert self.buflen != 0, "Buffer underflow!" x = self.buf[self.popi] self.buf[self.popi] = None self.buflen -= 1 self.ringlen -= 1 if self.ringlen == 0 and self.buflen != 0: self.popi = self.ringcap self.ringlen = self.buflen self.ringcap = self.bufcap else: self.popi = (self.popi + 1) % self.ringcap return x
This blog post presented an algorithm which supports the expansion of circular buffers while preserving most of their key characteristics. When not faced with an overflowing buffer, the algorithm should offer very similar performance characteristics to a normal circular buffer, with a few additional instructions and constant space for registers only. When faced with an overflowing buffer, the algorithm maintains the FIFO property and enables using contiguous allocated memory to maintain both the original circular buffer and the additional elements, and follows up reusing the full area as part of a new circular buffer in an attempt to find the proper size for the given use case.Read more