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.