Lock-free structures · Performance Lab

SPSC Ring Buffer

A bounded single-producer/single-consumer queue needs ownership discipline more than a heroic number of atomics — separate reservation from publication, payload read from acknowledgement, and correctness follows.

Learning objective

Explain a bounded SPSC ring buffer as an ownership-discipline problem: show why separating reservation from publication, and payload read from consumption acknowledgement, is what makes the structure correct, work through wrap-around, full, and empty detection, show the cached-cursor optimisation, contrast per-item publication with batching, and demonstrate two concrete correctness bugs a naive implementation can introduce.

Prerequisites

Theory

Why single-producer/single-consumer is a distinct case

An MPMC ring buffer needs contended, CAS-based cursor updates — the exact contention the CAS Contention and Backoff lab describes. An SPSC ring buffer sidesteps that entirely: the producer is the only thread that ever writes the head cursor, and the consumer is the only thread that ever writes the tail cursor. Neither cursor is ever contended. The remaining work isn't avoiding contention — there isn't any — it's correctly publishing what one thread wrote so the other observes it correctly, and correctly detecting full and empty without a lock.

Five separate phases, not two

Collapsing "produce" and "consume" into single monolithic operations is exactly where the bugs below come from. A correct implementation separates:

  1. Reservation (producer) — claim the next slot index. Doesn't touch the slot's memory yet.
  2. Payload write (producer) — write the value into the reserved slot. Not yet visible to the consumer.
  3. Publication (producer) — advance the head cursor. This is the release-store: the write in step 2 must be ordered strictly before this, or the consumer can observe a "ready" slot that isn't (see "Bug: publish before write" below).
  4. Payload read (consumer) — read the value out of a published slot.
  5. Consumption acknowledgement (consumer) — advance the tail cursor. Only after this may the producer legally overwrite that slot.

Wrap-around

Cursors (head, tail) are monotonically increasing counters, not values bounded to [0, N) — the actual slot is cursor % N (or cursor & mask for a power-of-two capacity). "Wrap-around" is just this arithmetic taking a cursor from index N-1 back to 0 — normal, correct behaviour, not an edge case. The only invariant: the producer never reserves a slot the consumer hasn't freed, and the consumer never reads a slot the producer hasn't published — both phrased in terms of the count of outstanding items (head - tail), never the wrapped index directly.

Full and empty detection

Full: the producer must not reserve when head - tail == capacity — doing so anyway is the overwrite bug below. This lab's model rejects and counts the rejection (backpressure) as the simplest honest choice. Empty: the consumer must not read when head == tail — nothing published yet.

The cached-cursor optimisation

Checking "is there room?" naively means reading the consumer's tail on every reservation — a cross-core coherence round trip. Since the producer only needs the tail to be accurate when its optimistic view says "maybe full," it can keep a cached copy of the consumer's tail (and symmetrically, the consumer caches the producer's head), refreshing only when the cache pessimistically suggests no room (or no data) is left. When the cache is already known sufficient, no cross-core read happens at all. This is the same idea behind cursor-caching in the LMAX Disruptor and comparable SPSC/MPSC queues.

Batch publication

Publishing one slot at a time costs one release-store per item. If the producer has reserved several contiguous slots, it can write all their payloads and then publish all of them with a single head update — the same idea as the consumer acknowledging several reads with one tail update. This amortises publication/acknowledgement cost across a batch without changing ordering within it, and without batching the payload read itself.

Bug: publish before write

If a producer advances the head cursor before finishing the payload write, the consumer can observe the slot as "published" and read it before the real payload exists. It doesn't crash: it silently returns whatever was previously there — stale data from an earlier cycle. This is why publication is its own explicit step, strictly after the write.

Bug: overwrite before consumption

If a producer's reservation step doesn't correctly check head - tail against capacity — or, as this lab's buggy scenario models, skips the check entirely — the producer will eventually overwrite a slot the consumer hasn't read. The old value is destroyed with no error and no signal. This is strictly worse than a crash: the program keeps running as if nothing went wrong.

Conceptual model

The interactive model below fixes a small capacity (2 or 4 slots) and a hand-authored turn order so each mechanism is reachable in a handful of steps — a real buffer's capacity is chosen for the actual workload's burst size. "Cache hit"/"cache refresh" counts are exact for one scripted sequence, not a measurement of real coherence-traffic savings — see "Benchmark methodology" for real, disclosed timing data.

Try it

Interactive ring-buffer model

Conceptual model

A deterministic, hand-scripted turn order through one producer and one consumer sharing a small ring buffer — not a scheduler or timing simulation. See "Limitations of this model" in the theory above for what this leaves out.

Two full produce/consume cycles: reserve, write, publish, then read, acknowledge. The baseline correct flow every other scenario is compared against.

Producer and consumer

Slots

Scenario
Normal flow
Step
0 of 10
Occupancy (head − tail)
0
Reserved / Published / Read / Acknowledged
0 / 0 / 0 / 0
Rejected reservations (full)
0
Starved reads (empty)
0
Batch publishes / acks
0 / 0
Overwrite bugs (data lost)
0
Incorrect reads (stale data)
0

Event log

  1. Initial state — buffer empty, nothing produced or consumed yet.

Java and Rust

Zero-allocation ring buffer

public final class SpscRingBuffer {
    private static final VarHandle HEAD;
    private static final VarHandle TAIL;
    static {
        try {
            HEAD = MethodHandles.lookup().findVarHandle(SpscRingBuffer.class, "head", long.class);
            TAIL = MethodHandles.lookup().findVarHandle(SpscRingBuffer.class, "tail", long.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    private final long[] slots;
    private final int mask;
    private volatile long head = 0; // published cursor
    private volatile long tail = 0; // acknowledged cursor
    private long cachedTail = 0, reserveIndex = 0; // producer-only
    private long cachedHead = 0, readIndex = 0;    // consumer-only

    public SpscRingBuffer(int capacity) {
        if (Integer.bitCount(capacity) != 1) throw new IllegalArgumentException("capacity must be a power of two");
        this.slots = new long[capacity];
        this.mask = capacity - 1;
    }

    public boolean tryProduce(long value) {
        int capacity = slots.length;
        if (reserveIndex - cachedTail == capacity) {
            cachedTail = (long) TAIL.getAcquire(this);
            if (reserveIndex - cachedTail == capacity) return false; // genuinely full
        }
        slots[(int) (reserveIndex & mask)] = value; // payload write — not yet visible
        reserveIndex++;
        HEAD.setRelease(this, reserveIndex); // publication
        return true;
    }

    public boolean tryConsume(long[] out) {
        if (readIndex == cachedHead) {
            cachedHead = (long) HEAD.getAcquire(this);
            if (readIndex == cachedHead) return false; // genuinely empty
        }
        out[0] = slots[(int) (readIndex & mask)]; // payload read
        readIndex++;
        TAIL.setRelease(this, readIndex); // consumption acknowledgement
        return true;
    }
}

No boxing, no allocation, on either call — out is caller-allocated once and reused. HEAD/TAIL use VarHandle.setRelease/getAcquire because that's the only synchronization actually required; cachedTail/cachedHead are plain fields, touched only by their owning thread.

JMH benchmark

@State(Scope.Group)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(1)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
public class SpscRingBufferBenchmark {
    private final SpscRingBuffer buffer = new SpscRingBuffer(1024);
    private final long[] out = new long[1];

    @Benchmark @Group("spsc") @GroupThreads(1)
    public void produce() {
        while (!buffer.tryProduce(1)) Thread.onSpinWait();
    }

    @Benchmark @Group("spsc") @GroupThreads(1)
    public void consume() {
        while (!buffer.tryConsume(out)) Thread.onSpinWait();
    }
}

The runnable Maven/JMH project (with correctness tests) is at content/labs/spsc-ring-buffer/code/java/ in this site's repository.

Benchmark methodology

Measured

JMH 1.37, OpenJDK 26.0.1 (HotSpot), Apple M1 Max (10 cores: 8P + 2E), 64 GB unified memory, macOS 26.5.1, arm64. Rust: Criterion 0.5.1, rustc 1.88.0, same machine. Java: 1 fork, 5 warmup + 10 measurement iterations of 1 second each, Mode.Throughput, @Group with one producer thread and one consumer thread pinned to a 1024-slot buffer for the whole run. Rust: default Criterion sampling (100 samples), each sample spawning a fresh producer/consumer thread pair moving 200,000 items, then joining both. Single developer machine with ordinary desktop load running alongside — not a dedicated, thermally-stable rig.

Both languages benchmark the same shape but measure different things — the numbers must not be compared directly. JMH's @Group keeps both threads alive for the entire measurement window, reporting steady-state throughput. Criterion's b.iter(...) re-runs the whole closure — including thread::spawn/.join() for a fresh pair — every sample, so its time necessarily includes thread creation/teardown overhead the Java number excludes.

Java (JMH, @Group("spsc"), ops/ms per side, higher is better):

BenchmarkThroughput99.9% CI
spsc (combined)24,850.079 ops/ms±412.206
spsc:produce12,424.844 ops/ms±206.229
spsc:consume12,425.235 ops/ms±205.978

Rust (Criterion, full pipeline including thread spawn/join, 200,000 items per sample):

MetricValue
Median pipeline time907.47 µs
Min–max (100 samples)864.16 µs – 955.78 µs
Derived items/ms (from median)≈220,392

Producer and consumer throughput match almost exactly in the Java run (12,424.844 vs. 12,425.235 ops/ms) — expected for a correctly balanced SPSC pipeline where both sides do symmetric, equally-cheap work. The Rust number cannot be read as "faster than Java" here — its derived ~220,392 items/ms includes real thread spawn/join overhead the Java methodology deliberately excludes; a persistent-thread harness would very likely change it substantially. Neither number is a "real-world" figure — both run the tightest possible spin-loop with no actual payload processing, isolating the ring buffer's own mechanics from any application work.

Re-run the projects in code/ on your own hardware before treating any of this as authoritative for a design decision.

Common mistakes

  • Publishing (advancing head) before finishing the payload write — the consumer can observe and read stale data with no error at all (see "Bug: publish before write").
  • Getting the full-buffer check wrong, or skipping it — the producer silently overwrites unconsumed data (see "Bug: overwrite before consumption").
  • Checking wrapped slot indices directly for "full"/"empty" instead of comparing raw cursor counts (head - tail) — this breaks the instant a cursor wraps.
  • Choosing a non-power-of-two capacity when using a bitmask for the slot index — index & mask is only equivalent to index % capacity when capacity is a power of two.
  • Reading the other side's real cursor on every single operation when a cached, refresh-on-demand copy would avoid nearly all of that cross-core traffic.

When an SPSC ring buffer is the right structure

Use an SPSC ring buffer when

  • There is genuinely exactly one producer thread and exactly one consumer thread — the single-writer guarantee is what removes all contention.
  • The workload is bursty enough that batching several items per publish/acknowledge is worth the added bookkeeping.
  • Bounded memory and explicit backpressure (reject/block on full) are acceptable, rather than an unbounded queue.

Don't reach for it when

  • More than one thread needs to produce or consume — that's an MPSC/MPMC problem with real contention to manage (see CAS Contention and Backoff), not this lab's non-goal.
  • An unbounded queue is actually what the workload needs — a fixed capacity forces an explicit backpressure policy decision up front.
  • The payload per item is large enough that the fixed-size slot array's memory footprint, not the synchronization discipline, becomes the actual constraint.

Investigation task

Using the Java or Rust project in code/:

  1. Run the benchmark and record the reported throughput or pipeline time on your own hardware.
  2. Change the buffer capacity (a power of two) and re-run — observe whether throughput changes, and form a hypothesis for why or why not.
  3. In the Java benchmark, temporarily change tryProduce/tryConsume to always re-read the real tail/head (remove the cached-cursor short-circuit) and re-run — measure the throughput cost of the cache being absent.
  4. Reproduce the "publish before write" bug in your own copy of the code by moving the HEAD.setRelease call before the payload write, and write a test that demonstrates the consumer can observe incorrect data (it will not do so on every run — note how often it reproduces on your hardware, and why that non-determinism doesn't make the bug any less real).
  5. Write down your CPU model and core count, your measured numbers, and explain any difference from the disclosed numbers above.

Accessibility notes

  • The interactive model is fully keyboard operable: the scenario selector and the Java/Rust code tabs are roving-tabindex tablists (arrow keys, Home/End); every step/reset control is a native <button>.
  • Every state change is announced to screen readers via an aria-live="polite" region, in addition to the visible slot/actor status badges and the state inspector table — state is never colour-only.
  • This page has no auto-playing or looping animation; step transitions resolve instantly, so there is nothing beyond the site-wide CSS rule to gate on prefers-reduced-motion.
  • With JavaScript disabled, this page's theory, Java and Rust code, benchmark methodology, trade-offs and sources remain fully readable — only the interactive model and the graded quiz require script.

Review questions

Self-check before or after the graded quiz below:

  1. Why must publication be a separate, later step from the payload write, rather than the same operation?
  2. What specifically does the cached-cursor optimisation avoid, and when is it forced to give up and re-read the real cursor?
  3. Why is "wrap-around" not an error condition, and what invariant must hold regardless of whether a cursor has wrapped?
  4. What's the difference between how this lab's "full" scenario and its "overwrite" bug scenario each handle a full buffer?

Quiz

Sources