quantfold

Ring buffers are underrated

Every service I have run eventually grows a "keep the last N things" requirement. Last 100 requests for a debug endpoint. Last 4096 samples for a moving average. Last few seconds of audio. The first draft is always a slice that gets appended to and trimmed, and the second draft is always a ring buffer.

The shape

A fixed array, a write index, and a count. Writes go to buf[head], then head = (head + 1) & mask. If the capacity is a power of two, the mask replaces a modulo, which matters when you are writing millions of samples a second and not at all otherwise.

type Ring[T any] struct {
    buf  []T
    head int
    n    int
}

func (r *Ring[T]) Push(v T) {
    r.buf[r.head] = v
    r.head = (r.head + 1) & (len(r.buf) - 1)
    if r.n < len(r.buf) {
        r.n++
    }
}

The off-by-one

Reading the oldest element is where people trip. When the buffer is full, the oldest element is at head, not head - n. When it is not full yet, the oldest is at index zero. Encode that as one expression, (head - n) & mask, and both cases fall out, but only if you have already committed to the power-of-two capacity. Otherwise you need the branch.

Why not a deque

A deque from the standard library will allocate as it grows, which is fine until the thing you are logging is the allocator. Ring buffers never allocate after construction, which is the whole point. They also give you free overwrite semantics: the oldest thing goes away and nobody has to decide when.

The one case where I reach for something else is when consumers are slower than producers and dropping data is unacceptable. Then you want backpressure, and a ring buffer will happily lie to you by overwriting.