← The Ledger
Vol. I, No. 5 · Queue Structures

The Ring Buffer

Fixed memory. Two indices. Around and around.
loading…
The Hook

A producer writes events; a consumer reads them. The producer does not stop, and the consumer does not always keep up. A queue that grows without bound absorbs the difference by eating memory until the process dies.

The fix is to decide the memory budget up front. Allocate one block, never resize it, and make the queue live inside it. The question becomes: how does a fixed array behave like an endless queue?

The Shape

A ring buffer is an ordinary array plus two indices. The tail marks where the next write goes; the head marks where the next read comes from. Both start at slot 0.

That is the entire representation: one allocation, two integers. No nodes, no pointers between elements, no per-item bookkeeping. The elements sit contiguously in memory in the order they arrived.

Push

To push, write the value into the slot the tail points at, then advance the tail by one. Two operations: a store and an increment.

Nothing shifts and nothing allocates. Compare an array-backed queue that dequeues from the front by sliding every remaining element left: that is O(n) per operation. The ring buffer's push is O(1) with a constant small enough to sit inside an interrupt handler.

Pop

Pop mirrors push: read the slot the head points at, then advance the head. The region between head and tail is the live queue; everything outside it is free space waiting to be reused.

Notice that popped slots are not cleared in any physical sense. The value stays in memory until a later push overwrites it. The indices define what is logically present; the array itself never changes shape.

Wrap-Around

Keep pushing and the tail reaches the last slot. The next advance would fall off the end of the array — so it doesn't. Slot 7's successor is slot 0: next = (i + 1) % capacity.

This is the trick in the name. The array is a straight line in memory, but the indices treat it as a circle. As long as pops keep freeing slots behind the head, pushes can continue forever inside the same eight slots. The buffer recycles space instead of requesting more.

Full Or Empty

The circle introduces one ambiguity. When head equals tail, the buffer is either empty (the consumer caught up) or full (the producer lapped the consumer). The indices alone cannot say which.

Three standard resolutions. Keep an explicit count of live elements. Or sacrifice one slot, declaring the buffer full at capacity − 1 so full and empty are distinguishable states. Or let the indices increment forever without wrapping and apply the modulo only on access: then tail − head is exactly the count, which is what the Linux kernel's kfifo does.

Overwrite Mode

Blocking or rejecting a push is not the only policy. A logging buffer usually prefers to overwrite the oldest entry: when the buffer is full, the new event replaces the one the head points at, and the head advances too.

The result is a structure that always holds the most recent N events. That is precisely what a flight recorder, a kernel log, or a debug trace wants — the recent past matters, the distant past does not. dmesg reads exactly such a ring.

Power Of Two

The wrap costs a modulo on every operation, and integer division is one of the slower things an ALU does. If the capacity is a power of two, the modulo collapses to a bit-mask: (i + 1) & (capacity − 1).

This is why production ring buffers round their capacity up to 8, 1024, or 65536 rather than accepting an arbitrary size. One AND instruction per operation, and the index arithmetic also stays correct across unsigned integer overflow when the indices run free.

Lock-Free

With one producer and one consumer, the ring buffer has a property most queues lack: each index has exactly one writer. The producer alone moves the tail; the consumer alone moves the head. Neither thread ever writes to the other's index.

That ownership split makes the single-producer single-consumer queue lock-free with only memory-ordering fences: the producer writes the element first and publishes the new tail second, so the consumer can never observe an index that points at unwritten data. This is the queue inside audio callbacks and network drivers, where taking a mutex is not an option.

The Trade

The capacity is fixed at construction, and that is the cost: a full buffer must block, drop, or overwrite. Sizing it means knowing the burst behaviour of your producer.

It is also the feature. Bounded memory turns overload into backpressure — a full queue tells the producer to slow down — instead of an unbounded queue quietly consuming the heap. In exchange the structure delivers O(1) at both ends, zero allocation after startup, and contiguous memory that prefetchers love.

Where It Lives

Linux keeps its kernel log in a ring (dmesg prints it) and ships kfifo as a reusable primitive. io_uring is named for the shape: a submission ring and a completion ring shared between user space and kernel, replacing syscall-per-operation I/O. Network cards DMA packets into rx/tx descriptor rings for the same reason — hardware and driver run at different speeds, and the ring absorbs the difference.

Higher up: audio pipelines pass samples between real-time and normal threads through SPSC rings; the LMAX Disruptor built a trading exchange around one large ring; Rust's VecDeque is a growable ring buffer wearing a deque interface.

The Close

An array, allocated once, and two indices that chase each other around it. Push is a store and an increment; pop is a load and an increment; the wrap is a mask. Full versus empty needs one extra bit of care, and single-producer single-consumer needs no lock at all.

The array never grows. The indices just keep going around — which is the whole design: fixed space, constant time, and overload handled by policy instead of by allocation.

Sources & further reading

Every claim in this chapter traces to a primary source. Peer-reviewed papers are linked by DOI.

Foundations

  1. Knuth, D. E. (1997). The Art of Computer Programming, Vol. 1, §2.2.2 — circular queues in sequential allocation. www-cs-faculty.stanford.edu/~knuth/taocp.html
  2. Ring buffer / circular buffer — overview and boundary conditions. en.wikipedia.org/wiki/Circular_buffer

Concurrency

  1. Thompson, M. et al. (2011). Disruptor: High performance alternative to bounded queues. LMAX technical paper. lmax-exchange.github.io/disruptor/disruptor.html
  2. Axboe, J. (2019). Efficient IO with io_uring. kernel.dk/io_uring.pdf

In practice

  1. Linux kernel — kfifo, the generic in-kernel ring buffer. www.kernel.org/doc/html/latest/core-api/kfifo.html
  2. Rust standard library — VecDeque, a ring-buffer-backed deque. doc.rust-lang.org/std/collections/struct.VecDeque.html
  3. JUCE audio framework — AbstractFifo for real-time audio handoff. docs.juce.com/master/classAbstractFifo.html

Enjoyed this chapter? Support The Ledger.