# Ring Buffers: The Data Structure at Every Fast Boundary

> NIC queues, io_uring, audio pipelines, logging, SPSC channels — wherever a fast producer meets a consumer on a budget, there's a fixed array with two indices. The design decisions that matter: power-of-two sizing, the full/empty problem, and who waits.

URL: https://harshith.in/blog/ring-buffers/
Author: Harshith Sunku (Staff Engineer) — https://harshith.in/about/
Tags: systems, performance, concurrency

Look at any boundary in a system where data crosses between actors that run
at different rhythms — hardware and driver, interrupt and thread, producer
and consumer — and you'll find the same structure: a fixed-size array, a head
index, a tail index. NIC descriptor rings, io_uring's SQ/CQ, ALSA audio
buffers, the kernel's dmesg log, ftrace, every SPSC channel in every runtime.
The ring buffer is what a queue becomes when allocation is forbidden and
latency is accounted in nanoseconds — and its three classic design decisions
are worth knowing before you inevitably write one.

## The Core, and Decision One: Don't Wrap, Mask

Producer writes at `tail`, consumer reads at `head`, both advance forever and
wrap modulo capacity. The naive wrap is a comparison or a `%` — a division.
The idiomatic version sizes the buffer to a power of two and masks:

```c
struct ring {
    uint32_t head, tail;      /* free-running, never wrapped */
    void    *slot[1024];      /* power of two */
};
#define MASK(i) ((i) & 1023)

/* produce */  r->slot[MASK(r->tail)] = item; r->tail++;
/* consume */  item = r->slot[MASK(r->head)]; r->head++;
```

Letting the indices run free (wrapping only at 2³²) is decision two solved
for free: **full vs empty**. With wrapped indices, `head == tail` means
either; the folkloric fixes (waste one slot, keep a count) each cost
something. Free-running indices distinguish them exactly:
`tail - head == 0` is empty, `== capacity` is full — and unsigned overflow
does the right thing forever, provided capacity divides 2³². The kernel's
kfifo works precisely this way, and its comments are the best documentation
the pattern has.

## Decision Three: What Happens When It's Full

A full ring is a *policy* moment, and picking the wrong policy is a systems
design error, not a bug:

- **Backpressure** (block/return EAGAIN): correctness-critical data — job
  queues, network sends. The slowdown propagates upstream, which is the
  point.
- **Overwrite oldest**: telemetry and diagnostics — dmesg, flight recorders,
  ftrace. The newest data is worth more than the oldest; a wedged consumer
  must not wedge the producers.
- **Drop newest**: rare; when processing order is sacred and staleness
  beats reordering.

The famous deployments are policy choices you can now read: NIC RX rings
drop packets when full (backpressure to *the internet* isn't a thing — the
counter is `rx_ring_full` in ethtool stats); io_uring makes the application
own both rings so the kernel never blocks on you; audio rings *underrun*
audibly, which is why their sizing is a latency/reliability dial exposed to
the user.

## The Lock-Free Payoff: SPSC

The ring's celebrity comes from the single-producer/single-consumer case,
where it goes lock-free with nothing but two atomics: the producer *owns*
`tail` and only reads `head`; the consumer owns `head` and only reads `tail`.
Each index has exactly one writer — no CAS, no contention, just ordering:

```c
/* producer */
if (tail - atomic_load_explicit(&r->head, memory_order_acquire) == CAP)
    return FULL;
r->slot[MASK(tail)] = item;
atomic_store_explicit(&r->tail, tail + 1, memory_order_release);
```

The release store publishes the slot write; the consumer's acquire load of
`tail` guarantees it sees the item, not garbage. That acquire/release pair is
the entire synchronization — this structure is *why* interrupt handlers can
hand data to threads and real-time audio callbacks can talk to UI threads
without ever taking a lock. Two production-grade refinements: pad head and
tail onto separate cache lines (they're written by different cores — false
sharing otherwise turns your lock-free ring into a coherence-traffic
generator), and cache the observed remote index locally so you only reread
it when the cached value says full/empty (halves the coherence traffic;
this is most of the difference between a textbook ring and folly's
ProducerConsumerQueue).

MPMC is a different animal — multiple writers must claim slots (CAS on
sequence numbers à la the Vyukov queue, or just a mutex, which is honestly
fine at most rates). The step from SPSC to MPMC costs more than people
expect; sharding N SPSC rings is often the better trade.

## Takeaways

- Fixed array + free-running head/tail + power-of-two mask: no division, and
  full/empty disambiguate as tail-head ∈ {0, capacity}.
- Full-buffer policy is a design decision — backpressure for correctness,
  overwrite-oldest for telemetry — and every famous ring picked deliberately.
- SPSC goes lock-free with one release store and one acquire load; each index
  has a single writer by construction.
- Production details: separate cache lines for the indices, cache the remote
  index, size for the burst not the average.
- MPMC is a genuinely harder structure; prefer sharded SPSC before reaching
  for CAS heroics.
