Why this matters: the core design is correct; this lesson is about where it's slow, and about the neighboring designs an interviewer may steer toward. The performance story of a ring buffer lives almost entirely in cache behavior — which means some of the biggest wins are invisible in the code's logic and show up only in its memory layout.
False sharing: the invisible tax
Our struct puts head and tail next to each other, which very likely lands them on the same 64-byte cache line. Logically they have different writers and never conflict. Physically, the cache coherence protocol works in whole lines: every time the producer stores tail, the consumer's cached copy of that line — which it needs for head, its own counter — is invalidated, and vice versa. Two threads that share no data end up ping-ponging a cache line between cores on every operation. That's false sharing: contention created by layout, not by logic.
The fix is padding — spend memory to buy isolation:
struct ring {
struct record slots[CAPACITY];
alignas(64) atomic_u64 tail; // own cache line
char pad1[56];
alignas(64) atomic_u64 head; // own cache line
char pad2[56];
};A hundred-odd bytes of padding routinely buys integer-factor throughput on a hot SPSC queue. In an interview, raising false sharing unprompted — with the mechanism, not just the term — is one of the strongest systems signals available, precisely because the code was already "correct" without it.
A refinement worth naming once you're here: each side also repeatedly acquire-loads the other side's counter, re-pulling that line even when nothing changed. Caching the last-seen value of the other index locally, and only re-loading when the cached value says full (or empty), cuts that traffic further. Mention it as the next turn of the same crank.
Batching: amortizing the fences
Sometimes the producer has a burst of records — the NIC delivered twelve packets. Pushing them one at a time pays the acquire/release round-trip twelve times. A batched push writes all twelve records into consecutive slots, then publishes once:
try_push_n(records, n):
check space for n (one acquire of head)
copy n records into slots
store-release tail + n (one publish)
The consumer sees all twelve appear atomically-in-order, and per-record overhead collapses toward a pure memcpy. The symmetric try_pop_n exists for the same reason. The trade-off to state: batching adds latency for the first record in the batch — you're holding it back until its siblings are written — so a latency-first system batches only what arrives together, never waiting to fill a batch.
The waiting strategy: deliberately not our problem
try_push returning false leaves the caller holding a record and a decision. It's worth having the menu in your pocket, because "what should the caller do?" is a natural interviewer follow-up:
- Spin and retry — lowest latency, burns a core; right when rejection is rare and microseconds matter.
- Backoff — retry with escalating pauses; the middle ground.
- Sleep/park and be woken — cheapest CPU, highest wake-up latency, and it drags an OS primitive back into the picture.
- Drop the record — legitimate when freshness beats completeness (telemetry, market data snapshots).
The design point to make explicit: by keeping the buffer non-blocking, we made the waiting policy a caller-side seam. Different callers — a latency-critical ingest thread versus a background drainer — can choose different policies against the same buffer. Building any one policy into the buffer would have chosen for everyone; the API boundary is doing the same job the strategy interface did in the machine-coding problems.
Overwrite-oldest: a different contract, not a feature flag
Telemetry pipelines often want the opposite full-buffer behavior: never stall the producer; if the consumer is slow, lose the oldest data. It's tempting to see that as a mode flag on our design, and it isn't — it's a different contract with a different structure. The moment the producer can advance over unconsumed slots, it must move head too — and head acquires a second writer, which demolishes the one-writer-per-index property that our whole no-lock argument stood on.
Real overwrite-oldest designs restructure around that fact (per-slot sequence stamps that let a reader detect it lost a lap and resynchronize, for instance). The interview-ready summary: "rejection and overwrite-oldest look like siblings at the API, but they differ at the invariant level — I'd design overwrite-oldest as its own structure rather than bolt it onto this one." Knowing where a variation stops being a variation is the trade-off skill this lesson is named for.
Key takeaway
Past correctness, the ring buffer's story is cache lines and contracts: pad head and tail onto separate lines to kill false sharing, batch pushes to amortize the publish, keep waiting policies outside the buffer where each caller can choose — and recognize that overwrite-oldest isn't a flag but a different invariant structure entirely. Performance reasoning that names mechanisms, and variation reasoning that names contract boundaries, are what distinguish an L5+ answer here.