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:
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:
/* 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.