Free preview

Why this matters: the ring buffer is where systems interviews test whether you think in invariants — the properties that must hold no matter how the two threads interleave. The design fits in a paragraph; defending why it's correct without a lock is the actual content, and hand-waving here ("it's just two indices") is the most reliable way to fail the round.

The memory layout

One contiguous array of fixed-size slots, plus two indices:

slots:  [ 64B ][ 64B ][ 64B ][ 64B ][ 64B ][ 64B ][ 64B ][ 64B ]
                  ^                    ^
                head                  tail
        (consumer reads here)   (producer writes here)

head, tail: monotonically increasing counters
slot for position p  =  slots[p & (capacity - 1)]      // power of two

Records are copied in at tail's slot and copied out at head's slot. The power-of-two capacity turns the wrap into a mask — one AND instead of a modulo on every operation, which is exactly the kind of cost you volunteered to care about when you accepted "hot path."

The key layout decision: the indices are free-running counters, not pre-wrapped positions. They only ever increase; wrapping happens at the moment of slot lookup. That choice quietly solves a classic problem, which brings us to —

Full versus empty: the disambiguation

With wrapped indices, head == tail is ambiguous: it's what an empty buffer looks like and what a completely full buffer looks like. The classic fixes are to waste one slot (full means "tail is one behind head"), or to carry a separate count. With free-running counters the ambiguity never arises:

size  = tail - head        // both monotonic, so this is exact
empty = (size == 0)
full  = (size == capacity)

No wasted slot, no shared counter, and — worth saying in the interview — no third piece of mutable state for the two threads to fight over. Choose whichever disambiguation you can defend; this chapter uses free-running counters because they keep the mutable state to exactly two words. (Counter overflow: with 64-bit counters at even a billion operations per second, wraparound is centuries away — say that proactively.)

The invariants

State these explicitly — in the round, out loud, before code:

1. tail is written by the producer ONLY; head by the consumer ONLY
2. head <= tail <= head + capacity, always
3. slots in [head, tail) hold valid, fully-written records
4. a record is readable by the consumer only after it is
   completely written  (the publish rule, below)
5. a slot is reusable by the producer only after it is
   completely read

Invariant 1 is the reason no lock is needed: each index has exactly one writer, so there is no write-write race anywhere in the design. The producer reads head (to check fullness) but never writes it; the consumer reads tail (to check emptiness) but never writes it. One-writer-per-word is the whole trick, and it's a direct gift of the SPSC contract from lesson 01.

Why "no lock" still isn't "no discipline"

Here is where candidates with a single-threaded mental model walk off a cliff. No write-write races doesn't mean no ordering problem. The producer does two things: it writes 64 bytes of record into a slot, and it increments tail. If the consumer can observe the new tail before the record bytes are visible, invariant 3 is broken — the consumer reads garbage from a slot the counter claims is ready.

Nothing about "no lock" prevents that reordering. Compilers reorder stores; CPUs make stores visible to other cores out of order. Correctness needs an explicit rule, stated in memory-ordering terms:

producer:  write the record bytes into the slot
           THEN store tail with RELEASE ordering

consumer:  load tail with ACQUIRE ordering
           THEN read the record bytes

The release store says: every write before this one is visible to anyone who observes it. The acquire load says: everything the releasing thread wrote before is visible after this load. Together they make "the counter moved" mean "the record is there." The mirror rule governs the other direction: the consumer finishes reading the slot, then stores head with release; the producer load-acquires head before reusing a slot — so invariant 5 holds by the same mechanism.

If you can narrate that pairing in words — publish with release, observe with acquire, and why each side needs it — you've cleared the bar most candidates miss. If you instead say "I'll mark the indices volatile," you've announced the gap.

What we rejected, and why

A mutex around push and pop. Correct, simple — and it puts a lock acquisition on every operation of a structure whose entire reason to exist is per-operation latency. Worth saying rather than skipping: on this problem, the lock-free design isn't cleverness for its own sake; it's the requirement doing the choosing. (If the contract weren't SPSC, this trade-off would need rethinking from scratch — which is why the contract came first.)

A separate count variable. It re-introduces a word that both threads must write — destroying invariant 1, the one-writer property that made everything else safe. The size must stay derived, never stored.

Linked nodes instead of an array. Allocation on the hot path, pointer chasing instead of sequential access, cache misses per record. The fixed array is not a simplification; it's the performance design.

Key takeaway

The SPSC ring buffer is two free-running counters over a power-of-two array, and one structural fact does all the work: each counter has exactly one writer, so no lock is needed and full-versus-empty falls out of tail minus head. What remains is ordering discipline — the producer publishes with a release store after writing the record, the consumer observes with an acquire load before reading it, and the mirrored pair protects slot reuse. State the invariants; the code then just transcribes them.

Enjoying the preview?

Create a free account to unlock the rest of this course, the in-browser judge, and live AI mock interviews.

Sign up free to continue