RingBuffer
RingBuffer[A] is a fixed-size, lock-free queue for efficiently exchanging elements between producer and consumer threads with minimal contention and cache-line effects. Ring buffers use a circular array to recycle memory, eliminating garbage collection pressure from transient allocations. The module provides four specialized implementations tuned for different producer/consumer thread patterns:
final class SpscRingBuffer[A <: AnyRef](val capacity: Int) {
def offer(a: A): Boolean
def take(): A
def size: Int
def isEmpty: Boolean
def isFull: Boolean
def drain(consumer: A => Unit, limit: Int): Int
def fill(supplier: () => A, limit: Int): Int
}
final class SpmcRingBuffer[A <: AnyRef](val capacity: Int) {
def offer(a: A): Boolean
def take(): A
def size: Int
def isEmpty: Boolean
def isFull: Boolean
}
final class MpscRingBuffer[A <: AnyRef](val capacity: Int) {
def offer(a: A): Boolean
def take(): A
def size: Int
def isEmpty: Boolean
def isFull: Boolean
def drain(consumer: A => Unit, limit: Int): Int
}
final class MpmcRingBuffer[A <: AnyRef](val capacity: Int) {
def offer(a: A): Boolean
def take(): A
def size: Int
def isEmpty: Boolean
def isFull: Boolean
}
Motivation
Building low-latency systems — trading platforms, game engines, real-time event processors — requires careful control over memory allocation and CPU cache behavior. Standard JVM collections like LinkedList or ArrayDeque are convenient but have a cost: every enqueue/dequeue pair allocates a node, triggering garbage collection pauses that can destroy millisecond-scale latencies.
A naive approach is to pre-allocate a large Array[A] and manually manage head/tail indices:
var head = 0
var tail = 0
val array = new Array[String](1024)
def offer(x: String): Boolean = {
if (tail - head >= array.length) false // full
else {
array(tail % array.length) = x
tail += 1
true
}
}
def take(): String = {
if (head == tail) null // empty
else {
val x = array(head % array.length)
head += 1
x
}
}
This works for single-threaded code, but introduces a critical problem under concurrency: both threads read and write head and tail without synchronization, leading to lost updates, stale reads, and silent data corruption. Adding synchronized blocks solves the data race but reintroduces contention and latency pauses.
Ring buffers solve both problems with lock-free algorithms and cache-line padding. A ring buffer provides:
- No garbage collection — reuses the same array forever
- Lock-free access — uses atomic compare-and-swap (CAS) for coordination, avoiding mutex contention
- Predictable latency — no surprise GC pauses or lock waits
- Thread-specialized variants — choose SPSC, MPSC, SPMC, or MPMC based on your thread pattern
This module provides four implementations tuned for maximum throughput and minimal latency across all producer/consumer combinations.
Overview
Ring buffers are high-performance data structures for:
- Event queues in IO, networking, and game engines where throughput and latency matter
- Thread-safe work queues with bounded capacity to backpressure senders
- Inter-thread communication between producers and consumers with predictable latency
- Concurrent batch processing where producers fill elements and consumers drain them
Why Ring Buffers
Ring buffers excel when you need:
- Ultra-low latency — lock-free algorithms and cache-line padding eliminate pauses from contention
- Predictable throughput — no GC overhead since the same memory array is reused forever
- Bounded resources — fixed capacity prevents runaway memory growth and enables backpressure
- High concurrency — multiple implementations optimized for different thread patterns (SPSC, MPMC, etc.)
How do ring buffers compare to other queue and collection types?
Comparison with Java and Scala Alternatives
| Property | RingBuffer (ZIO Blocks) | java.util.Queue (ConcurrentLinkedQueue) | scala.collection.concurrent.TrieMap | Array + manual index | Disruptor |
|---|---|---|---|---|---|
| Allocation | Single upfront | Per-element nodes | Per-node allocations | Single upfront | Single upfront |
| Lock-free | Yes (CAS-based) | Yes | Yes (CASes on trie nodes) | Yes (if single-threaded) | Yes (CAS) |
| GC pressure | Minimal (reuses slots) | High (node garbage) | High (trie node garbage) | Minimal | Minimal |
| Bounded | Fixed capacity | Unbounded | Unbounded | Fixed | Fixed (bounded capacity) |
| Thread patterns | Four variants (SPSC, MPSC, SPMC, MPMC) | Multi-producer/multi-consumer | Multi-reader/multi-writer | Limited (see impl) | Multi-producer/consumer |
| Predictable latency | High (no GC) | Medium (GC pauses) | Medium (GC pauses) | High (if no contention) | High |
RingBuffer is ideal when you control both producer and consumer thread counts and want maximum performance. java.util.Queue is better if you need unbounded capacity; Disruptor is a comparable JVM alternative with similar guarantees.
Installation
Add the ZIO Blocks Ring Buffer module to your build.sbt:
libraryDependencies += "dev.zio" %% "zio-blocks-ringbuffer" % "@VERSION@"
For Scala.js cross-platform support:
libraryDependencies += "dev.zio" %%% "zio-blocks-ringbuffer" % "@VERSION@"
SpscRingBuffer — Single Producer, Single Consumer
SpscRingBuffer[A] is optimized for the simplest and fastest case: exactly one producer thread and one consumer thread. It uses the FastFlow algorithm to eliminate all cross-core cache traffic, achieving nanosecond-scale latencies with no volatile reads on the fast path.
Why FastFlow?
Understanding the FastFlow algorithm helps explain why SpscRingBuffer achieves such high performance.
Imagine two threads sharing data with a traditional lock-based queue like ArrayBlockingQueue. They use a mutex (lock) to coordinate:
- Producer thread: acquires lock, adds element, releases lock
- Consumer thread: acquires lock, removes element, releases lock
This works, but locks have a cost: when threads contend for the same lock, one thread blocks (goes to sleep) while waiting. Waking a thread is expensive (thousands of CPU cycles). Even lock-free queues using synchronized or volatile reads can cause cache-coherency traffic: when one CPU core writes to a variable another core reads, the entire cache line must be invalidated and transferred — a costly operation that slows down both cores.
The fundamental issue: if the producer has to read what the consumer wrote (or vice versa), their CPU caches constantly fight. This is called false sharing and can reduce throughput by 10x or more on heavily loaded systems.
The problem is that any read by the producer of the consumer's state (or vice versa) causes cache line bouncing. So FastFlow's radical idea: neither side should ever read the other's state.
Instead, the producer simply writes data into slots and marks them non-null. The consumer independently reads slots and takes any non-null values. The array slot's null/non-null status itself is the only coordination needed — a happens-before relationship written once by the producer, read once by the consumer. No locks, no atomic operations on the fast path, no reading the other side's counters.
The note-passing analogy:
- You (producer) have a row of empty desks between you and your friend (consumer)
- Empty desk =
null(available) - Note on desk = non-null value (message ready)
- You never look at your friend's side — you just place notes on empty desks
- Your friend never looks at your side — they just pick up notes they see
- No shouting "are you ready?", no waiting, no lock contention
This is how FastFlow solves the cache-coherency problem: since producer and consumer never read each other's counters, there's no cache line bouncing between CPU cores. All coordination happens through the slots themselves, which are written once and read once.
The look-ahead cache is a further optimization: the producer maintains a local cached limit (producerLimit) so they don't have to check every slot individually. It's like glancing ahead at the next N desks to see if they're empty, without actually bending over to look. This keeps the fast path extremely fast — just a local counter check and an array write.
Why FastFlow is fast:
- No locks — no thread ever blocks another
- Minimal cache coordination — producer and consumer touch separate memory locations; the array slot is written once, read once
- Write-once, read-once semantics — the slot's null/non-null status is the handshake
- Cache-line padding — producer and consumer indices padded to separate cache lines, eliminating false sharing
The result: lock-free communication that scales linearly with CPU count and achieves nanosecond-scale latencies. This is why FastFlow is the algorithm of choice for SPSC ring buffers in high-performance systems like trading platforms, game engines, and network stacks. (The slow path's retry on full buffers means the algorithm is not wait-free, though contention is rare.)
Algorithm
SpscRingBuffer uses the FastFlow pattern, originally developed for the C++ FastFlow framework and popularized in Java by JCTools and the LMAX Disruptor. The core insight: the array element's null/non-null state is the synchronization signal. The producer never reads consumerIndex; the consumer never reads producerIndex. This eliminates all cross-core cache traffic between the two sides.
How it works:
- The producer walks forward through the array, placing elements into slots that are currently
null. It marks a slot non-nullafter writing. The producer never reads what the consumer has consumed — it only checks its own cachedproducerLimitto know how many slots are available. - The consumer walks forward through the array, reading any slot that contains a non-
nullvalue. After reading, it writesnullto clear the slot. The consumer never reads the producer's index. - The slot's nullness itself is the coordination: written once by the producer, read once by the consumer. No locks, no atomic operations on the fast path, no reading the other side's counters.
Look-ahead cache: The producer maintains a local producerLimit (derived from consumerIndex during slow path). This allows the fast path to check a simple local counter instead of reading the volatile consumerIndex on every offer. The look-ahead step is max(1, min(capacity/4, 4096)), balancing between reducing consumer reads and memory usage.
Diagram
To see the single-producer single-consumer FastFlow algorithm in action, use this interactive stepper. Type any label, click Offer to enqueue or Take to dequeue, and watch how the producer and consumer coordinate using only release/acquire semantics — no CAS on either side.
Here is a complete walkthrough of every variable in the trace, in the order the algorithm computes them.
pIdx — the monotonic producer counter (plain load/store)
The producer reads pIdx with a plain load — no atomic operations at all. Since only one thread produces, no synchronization is needed. The producer maintains its own count, never reads consumerIndex, and advances pIdx after writing with a release store.
cIdx — the monotonic consumer counter
SPSC avoids reading consumerIndex entirely. Instead of checking the occupancy formula (pIdx - cIdx), the producer reads the array slot at producerIndex + lookAheadStep to determine if a slot is free (on the slow path when the look-ahead cache is exhausted). This slot-based approach eliminates all counter reads between producer and consumer.
size = pIdx − cIdx — the occupancy check
Both sides use the same occupancy formula, but only during slow paths (rare). On the fast path, neither side reads the other's counter; they rely on slot nullness (FastFlow) for synchronization.
slot = pIdx & mask or slot = cIdx & mask — the circular array index
Identical bitmask arithmetic: & mask replaces modulo, giving a slot in [0, capacity).
element = buf[slot] — no CAS, plain slot reads
Unlike SPMC and MPMC, the consumer reads the slot directly with an acquire load — because there's only one consumer, no read-before-CAS is needed. The consumer is the sole owner of the take path. After reading, the consumer nulls the slot with a release store, which serves two purposes: (1) signals to the producer that the slot is free, and (2) releases any bookkeeping updates to other cores via the release semantics.
Why SPSC doesn't read producer state
The consumer never reads pIdx. Instead, it reads the slot directly: if the slot is non-null, data is ready. If null, the buffer is empty. The producer wrote a non-null value via a release store, and the consumer reads via an acquire load, forming a happens-before pair. This is the core of FastFlow: eliminate cross-core reads entirely.
The diagram marks slots as "live" (still owned by the producer-consumer pair) or "stale" (consumed but not yet overwritten). Unlike SPMC where consumers CAS and have ordering concerns, SPSC clears immediately because only one consumer exists — there's no race.
Creating Instances
Ring buffers are instantiated via the companion object's apply method. SpscRingBuffer[A] uses the FastFlow pattern with a look-ahead cache. On the fast path, the producer checks a cached producerLimit to avoid reading consumerIndex. When the cached limit is exhausted, the slow path reads the array slot at producerIndex + lookAheadStep — never consumerIndex directly. This keeps the producer and consumer cache lines fully independent. The consumer uses null/non-null slot reads (FastFlow semantics). Together, these avoid repeated volatile reads and minimize cross-core cache traffic.
object SpscRingBuffer {
def apply[A <: AnyRef](capacity: Int): SpscRingBuffer[A]
}
Creates a new SPSC ring buffer with the given capacity. The capacity must be a positive power of two.
We create an SPSC buffer as follows:
import zio.blocks.ringbuffer.SpscRingBuffer
val rb = SpscRingBuffer[String](16) // capacity must be power of 2
Operations
Inserting Elements — offer
SpscRingBuffer#offer — Insert an element with this signature:
final class SpscRingBuffer[A <: AnyRef](val capacity: Int) {
def offer(a: A): Boolean
}
Inserts the element without blocking. Returns true if inserted, false if the buffer is full. Throws NullPointerException if the element is null. Must be called from the producer thread only.
We can offer elements and check the return value to handle backpressure:
import zio.blocks.ringbuffer.SpscRingBuffer
val rb = SpscRingBuffer[String](4)
When the buffer becomes full, offer returns false:
val result1 = rb.offer("a")
val result2 = rb.offer("b")
val result3 = rb.offer("c")
val result4 = rb.offer("d")
val result5 = rb.offer("e")
Removing Elements — take
SpscRingBuffer#take — Remove an element with this signature:
final class SpscRingBuffer[A <: AnyRef](val capacity: Int) {
def take(): A
}
Retrieves and removes an element from the front of the buffer. Returns immediately without blocking. Returns the element, or null if the buffer is empty. Must be called from the consumer thread only.
We can take elements in FIFO order from our buffer:
rb.take()
rb.take()
rb.take()
rb.take()
rb.take()
Checking State — size, isEmpty, isFull
Ring buffers provide three query methods to check their state. Note that under concurrent access, these results are approximate — by the time the method returns, other threads may have modified the buffer.
SpscRingBuffer#size — Approximate element count with this signature:
final class SpscRingBuffer[A <: AnyRef](val capacity: Int) {
def size: Int
}
Returns the approximate number of elements currently in the buffer. Under concurrent access, the result may be stale. O(1).
SpscRingBuffer#isEmpty — Check if empty with this signature:
final class SpscRingBuffer[A <: AnyRef](val capacity: Int) {
def isEmpty: Boolean
}
Returns true if the buffer contains no elements (approximate). O(1).
SpscRingBuffer#isFull — Check if full with this signature:
final class SpscRingBuffer[A <: AnyRef](val capacity: Int) {
def isFull: Boolean
}
Returns true if the buffer is at capacity (approximate). O(1).
All four implementations provide the same three methods. We can check state after operations:
import zio.blocks.ringbuffer.SpscRingBuffer
val rb2 = SpscRingBuffer[String](8)
State queries are cheap but approximate under concurrency:
rb2.offer("x")
rb2.offer("y")
rb2.size
rb2.isEmpty
rb2.isFull
Batch Operations — drain and fill
SpscRingBuffer#drain — Consume up to N elements with this signature:
final class SpscRingBuffer[A <: AnyRef](val capacity: Int) {
def drain(consumer: A => Unit, limit: Int): Int
}
Removes up to limit elements from the buffer, passing each to the consumer callback. Returns the number of elements actually drained. Throws IllegalArgumentException if limit is negative. Must be called from the consumer thread only. O(n) where n is the number of elements drained. If the callback throws, all elements passed to it up to that point remain consumed and the buffer is in a consistent state.
SpscRingBuffer#fill — Produce up to N elements with this signature:
final class SpscRingBuffer[A <: AnyRef](val capacity: Int) {
def fill(supplier: () => A, limit: Int): Int
}
Inserts up to limit elements by calling the supplier for each new element. Returns the number of elements actually inserted. Throws IllegalArgumentException if limit is negative. Throws NullPointerException if the supplier returns null. Must be called from the producer thread only. O(n) where n is the number of elements inserted.
Batch operations help amortize synchronization costs. We can drain multiple elements at once:
import zio.blocks.ringbuffer.SpscRingBuffer
val rb3 = SpscRingBuffer[java.lang.Integer](16)
(1 to 5).foreach(i => rb3.offer(Integer.valueOf(i)))
val collected = scala.collection.mutable.Buffer[java.lang.Integer]()
val drained = rb3.drain(collected += _, 10) // drained = 5
println(s"Drained items: ${collected.mkString(", ")}")
Fill avoids repeated offer calls when producing elements:
import zio.blocks.ringbuffer.SpscRingBuffer
import java.util.concurrent.atomic.AtomicInteger
val rb4 = SpscRingBuffer[String](8)
val counter = new AtomicInteger(0)
val filled = rb4.fill(() => { counter.incrementAndGet(); s"item-${counter.get()}" }, 5)
println(s"Filled $filled items")
Example
SPSC: Producer-Consumer Ping-Pong
In a single-producer, single-consumer setup, use SpscRingBuffer for maximum throughput. This example demonstrates how two threads communicate efficiently using FastFlow signaling.
import docs.SourceFile
SourceFile.print("zio-blocks-examples/src/main/scala/ringbuffer/SpscExample.scala")
(source)
sbt "zio-blocks-examples/runMain ringbuffer.SpscExample"
Batch Fill and Drain (SPSC)
Use fill and drain for efficient batch operations. This example shows how to amortize synchronization costs by processing multiple elements at once.
import docs.SourceFile
SourceFile.print("zio-blocks-examples/src/main/scala/ringbuffer/BatchExample.scala")
(source)
sbt "zio-blocks-examples/runMain ringbuffer.BatchExample"
SpmcRingBuffer — Single Producer, Multiple Consumers
SpmcRingBuffer[A] allows a single producer thread to efficiently feed multiple consumer threads. It uses an index-based algorithm where slot validity is determined by comparing producer and consumer indices, allowing multiple consumers to coordinate safely via CAS operations on a shared consumer index.
Algorithm
SpmcRingBuffer allows a single producer to feed multiple consumers. The algorithm is index-based: slot validity is determined by comparing producerIndex and consumerIndex, not by null-checking slots.
How it works:
- The producer is single-threaded and never uses CAS. It maintains a
producerLimitderived from reading the volatileconsumerIndex. On the fast path, it checks its local limit; when exhausted, it refreshes by readingconsumerIndex. This design avoids the producer ever reading consumer state on the fast path. - The consumers (any number) coordinate via a CAS loop on
consumerIndex. Each consumer reads the element at its claimed index, then attempts to CASconsumerIndexforward. If CAS fails, it retries with a refreshed index. This ensures each element is claimed by exactly one consumer. - No slot clearing by consumers: Consumers do not write
nullafter reading. Instead, the producer safely overwrites slots once itsproducerLimitcheck (based onconsumerIndex) confirms all consumers have advanced past them. This eliminates the race that would occur if a consumer claimed a slot but hadn't yet cleared it.
Trade-off: Consumer CAS introduces overhead under contention, but the producer remains extremely fast (no synchronization).
Diagram
To see the single-producer multi-consumer algorithm in action, use this interactive stepper. Type any label, click Offer to enqueue (single producer) or Take to dequeue (any consumer), and watch the trace panel show every intermediate variable — pIdx, cIdx, size, slot, element — and the exact decision each side makes.
Here is a complete walkthrough of every variable in the trace, in the order the algorithm computes them.
pIdx — the monotonic producer counter
The producer has its own index that starts at zero and only ever increases. Because only one thread produces, pIdx is read and written with plain loads and stores — no CAS is needed. The producer's job is simple: check capacity (pIdx - cIdx < capacity), write to the slot, then advance pIdx.
cIdx — the monotonic consumer counter (multi-threaded)
All consumers share a single consumerIndex counter. Because multiple consumers may be operating concurrently, each consumer reads cIdx with volatile semantics to see the most recent updates, then attempts to atomically advance it via CAS. If the CAS fails, another consumer claimed the slot first and the reader discards their read and retries.
size = pIdx − cIdx — the occupancy check
Both sides use the same occupancy formula: pIdx − cIdx is the number of elements currently in the buffer. The producer checks if size == capacity to detect a full buffer. The consumer checks if size == 0 to detect an empty buffer.
slot = pIdx & mask or slot = cIdx & mask — the circular array index
Identical to all other ring buffer variants: because capacity is a power of two, the bitwise AND & mask replaces modulo division, giving a slot number in [0, capacity). So pIdx = 7 maps to slot 7 & 3 = 3, and pIdx = 8 wraps back to slot 0.
element = buf[slot] — read-before-CAS
This is the critical SPMC distinction. The consumer reads the element before attempting the CAS on cIdx. This ordering is essential: once the CAS succeeds and cIdx advances, the producer is permitted to overwrite that slot on its next lap. By reading the element first, the consumer guarantees it captures the value while the slot is still logically owned.
In the trace, when a consumer takes an element, you will see the element row highlighted — this captures the read that must happen before the CAS.
Why SPMC doesn't clear slots
Unlike MPSC (which uses FastFlow's null-check pattern), SPMC determines slot validity purely by index comparison: if cIdx ≤ slot < pIdx, the slot is live. After a consumer advances cIdx, the slot becomes "stale" — no longer live, but still holding the old value until the producer wraps around and overwrites it. This eliminates the race that would occur if a consumer had to null out the slot while holding the CAS; instead, the producer simply overwrites when it knows the consumer has advanced.
The diagram marks stale slots with a dashed border and a "stale" label in grey.
Creating Instances
SpmcRingBuffer[A] allows a single producer thread to offer elements while multiple consumer threads concurrently take elements via compare-and-swap on the consumer index.
object SpmcRingBuffer {
def apply[A <: AnyRef](capacity: Int): SpmcRingBuffer[A]
}
Creates a new SPMC ring buffer with the given capacity. The capacity must be a positive power of two.
We create an SPMC buffer as follows:
import zio.blocks.ringbuffer.SpmcRingBuffer
val rb = SpmcRingBuffer[java.lang.Integer](64)
Operations
Inserting Elements — offer
SpmcRingBuffer#offer — Insert an element with this signature:
final class SpmcRingBuffer[A <: AnyRef](val capacity: Int) {
def offer(a: A): Boolean
}
Inserts the element without blocking. Returns true if inserted, false if the buffer is full. Throws NullPointerException if the element is null. May be called from the single producer thread only; concurrent calls from multiple threads cause undefined behavior.
Removing Elements — take
SpmcRingBuffer#take — Remove an element with this signature:
final class SpmcRingBuffer[A <: AnyRef](val capacity: Int) {
def take(): A
}
Retrieves and removes an element from the front of the buffer. Returns immediately without blocking. Returns the element, or null if the buffer is empty. Thread-safe; multiple consumer threads may call this concurrently.
Checking State — size, isEmpty, isFull
Ring buffers provide three query methods to check their state. Note that under concurrent access, these results are approximate — by the time the method returns, other threads may have modified the buffer.
All four implementations provide the same method signatures: size, isEmpty, and isFull. See the SpscRingBuffer Operations section for detailed descriptions and examples of these methods.
MpscRingBuffer — Multiple Producers, Single Consumer
MpscRingBuffer[A] handles the inverse case: multiple producer threads safely offering elements to a single consumer thread. It uses a hybrid design combining producer-side CAS coordination with a FastFlow-style relaxed-poll consumer, balancing producer contention while keeping the consumer blazingly fast.
Algorithm
MpscRingBuffer handles multiple producers with a single consumer, based on the JCTools MpscArrayQueue design. It's a hybrid: producers use CAS among themselves, while the consumer remains FastFlow-style.
How it works:
- The producers (multiple) coordinate via CAS on the shared
producerIndex. Each producer claims a slot by atomically incrementingproducerIndex, then writes its element with release semantics. A cachedproducerLimit(initialized to capacity) reduces volatile reads ofconsumerIndex. - The consumer (single) uses FastFlow relaxed-poll semantics: it reads array slots directly. A
nullslot indicates either the buffer is empty or a producer has claimed the slot via CAS but has not yet written the element (mid-write). In both cases,takereturnsnullrather than spinning. The consumer clears the slot after reading. - The
producerLimitis updated opportunistically (racy updates are benign) to reflect approximate available capacity.
Why hybrid? Multiple producers need CAS to coordinate their offers, but the single consumer can achieve maximum speed using pure FastFlow (no CAS, no reading producer state).
Diagram
To see the hybrid algorithm in action, use this interactive stepper. Type any label, click Offer to enqueue or Take to dequeue, and watch the trace panel show every intermediate variable — pIdx, cIdx, size, slot, value — and the exact decision the algorithm makes.
Here is a complete walkthrough of every variable in the trace, in the order the algorithm computes them.
pIdx / cIdx — the monotonic counters
Like the MPMC algorithm, both counters start at zero and only ever increase — they never wrap, never reset, never go backwards. pIdx is shared by all producer threads: each producer atomically claims the next slot by performing a compare-and-swap (CAS) from pIdx to pIdx + 1. Whoever wins the CAS owns that slot. cIdx is read with a plain load because only one thread ever consumes, so no CAS is needed.
size = pIdx − cIdx — the occupancy check
Before claiming a slot, the producer checks whether the buffer is full: if pIdx − cIdx == capacity, every slot is occupied and offer returns false immediately. The difference pIdx − cIdx is the number of elements currently in the buffer. Under real concurrent access, the JVM implementation maintains a cached producerLimit to avoid reading the volatile consumerIndex on every offer call — but the fundamental check is the same occupancy test shown here.
slot = pIdx & mask — the circular array index
Because capacity is a power of two, mask = capacity - 1, and the bitwise AND strips high bits to give a slot number in [0, capacity). This is mathematically equivalent to pIdx % capacity but costs a single CPU instruction. So pIdx = 7 maps to slot 7 & 3 = 3, and pIdx = 8 wraps back to slot 0.
value = buf[slot] — the FastFlow slot check
The consumer uses the FastFlow relaxed-poll pattern: read the array slot directly with acquire semantics. If the slot is null, either the buffer is empty, or a producer has won the CAS but has not yet finished writing the element (mid-write). In both cases, take returns null rather than blocking or spinning — this is the relaxed poll semantic. This is why the consumer never reads pIdx; it derives all its information from the slot value itself.
Once the producer wins its CAS, it writes the element with release semantics. The acquire/release pair guarantees the consumer will see the fully written element as soon as the slot is non-null.
Creating Instances
MpscRingBuffer[A] allows multiple producer threads to offer elements concurrently via compare-and-swap on the producer index, while a single consumer thread takes elements efficiently.
object MpscRingBuffer {
def apply[A <: AnyRef](capacity: Int): MpscRingBuffer[A]
}
Creates a new MPSC ring buffer with the given capacity. The capacity must be a positive power of two.
We create an MPSC buffer as follows:
import zio.blocks.ringbuffer.MpscRingBuffer
val rb = MpscRingBuffer[java.lang.Long](32)
Operations
Inserting Elements — offer
MpscRingBuffer#offer — Insert an element with this signature:
final class MpscRingBuffer[A <: AnyRef](val capacity: Int) {
def offer(a: A): Boolean
}
Inserts the element without blocking. Returns true if inserted, false if the buffer is full. Throws NullPointerException if the element is null. Thread-safe; multiple producer threads may call this concurrently.
Removing Elements — take
MpscRingBuffer#take — Remove an element with this signature:
final class MpscRingBuffer[A <: AnyRef](val capacity: Int) {
def take(): A
}
Retrieves and removes an element from the front of the buffer. Returns immediately without blocking. Returns the element, or null if the buffer is empty. Must be called from the single consumer thread only.
Checking State — size, isEmpty, isFull
All four implementations provide the same method signatures: size, isEmpty, and isFull. See the SpscRingBuffer Operations section for detailed descriptions and examples of these methods.
Batch Operations — drain
MpscRingBuffer#drain — Consume up to N elements with this signature:
final class MpscRingBuffer[A <: AnyRef](val capacity: Int) {
def drain(consumer: A => Unit, limit: Int): Int
}
Removes up to limit elements from the buffer, passing each to the consumer callback. Returns the number of elements actually drained. Throws IllegalArgumentException if limit is negative. Must be called from the consumer thread only. O(n) where n is the number of elements drained.
Note: Uses relaxed poll semantics and stops at the first null slot, which may indicate either an empty buffer or a producer that has claimed a slot but has not yet written its element (mid-write). In the mid-write case, fewer than limit elements are returned even though more elements will become available shortly. If the callback throws, all elements passed to it up to that point remain consumed and the buffer is in a consistent state.
Example
MPSC: Multiple Producers, Single Aggregator
When multiple threads produce work for a single processor, use MpscRingBuffer. This example shows how three producer threads safely offer items to a single consumer.
import docs.SourceFile
SourceFile.print("zio-blocks-examples/src/main/scala/ringbuffer/MpscExample.scala")
(source)
sbt "zio-blocks-examples/runMain ringbuffer.MpscExample"
MpmcRingBuffer — Multiple Producers, Multiple Consumers
MpmcRingBuffer[A] is the fully general-purpose implementation for systems with multiple producer and consumer threads. It uses the Vyukov/Dmitry sequence-buffer algorithm, a sophisticated lock-free design that coordinates all access through monotonically increasing indices and per-slot sequence stamps—making every slot's state self-describing at any moment.
Algorithm
MpmcRingBuffer handles the hardest case: many producers and many consumers all accessing the same buffer at once. It uses the Vyukov/Dmitry algorithm, which is a lock-free MPMC queue design.
The challenge: when multiple producers might try to write to the same slot, and multiple consumers might try to read the same slot, we need a fair way to say "this slot is mine" without using locks.
The trick: Give each slot a "ticket number" that changes in a predictable cycle.
The algorithm runs two parallel arrays of the same length:
buffer[i]— holds the actual element at positioniseqBuf[i]— holds a sequence stamp at positioni
The sequence stamps are the heart of the algorithm. Each stamp encodes the ownership state of its slot: who is allowed to act on it and what they are allowed to do.
On initialization, seqBuf[i] is set to i. The producer index pIdx and consumer index cIdx both start at zero. From here, every operation follows the same pattern: read the stamp, compute a single difference (diff), and branch on whether diff is zero, negative, or positive.
At any moment, slot i (where i = index & mask) is in exactly one of three states:
| stamp value | meaning |
|---|---|
if seq == pIdx | Slot is free — the producer at pIdx may write |
if seq == cIdx + 1 | Data written — the consumer at cIdx may read |
if seq == cIdx + capacity | Slot consumed — free for the producer's next lap |
The producer looks for diff = seq - pIdx == 0. The consumer looks for diff = seq - (cIdx + 1) == 0. In both cases, a negative diff means the other side has fallen behind (buffer full or empty), and a positive diff means another thread already claimed this slot and you should retry.
Diagram
To see the sequence buffer in action, use this interactive stepper. The component below implements the algorithm faithfully in React. Type any label, click Offer to enqueue or Take to dequeue, and watch the trace panel show every intermediate variable — pIdx, slot, seq, diff — and the exact decision the algorithm makes from them.
Click "Step Producer" and "Step Consumer" to see how the algorithm coordinates access handoff without locks.
Here is a complete walkthrough of every variable in the trace, in the order the algorithm computes them.
pIdx / cIdx — the monotonic counters
These two numbers are the heartbeat of the entire algorithm. pIdx is the producer's counter and cIdx is the consumer's counter. They start at zero and only ever increase — they never wrap, never reset, never go backwards. After a million operations pIdx might be 1,000,000 and cIdx might be 999,996. The raw slot position is derived from them rather than stored directly, which is what makes the algorithm safe for multiple concurrent threads.
slot = idx & mask — the circular array index
Because the buffer has a power-of-two capacity (4 in the demo), mask = capacity - 1 = 3, which in binary is 0011. The bitwise AND strips everything above the lowest two bits, giving a number in the range [0, 3]. This is mathematically identical to idx % capacity but costs a single CPU instruction instead of a division. So pIdx = 7 maps to slot 7 & 3 = 3, and pIdx = 8 maps back to slot 8 & 3 = 0 — that is the wrap-around.
seq = seqBuf[slot] — the sequence stamp
Every slot carries its own stamp, completely independent of the other slots. The stamp is not a lock and not a boolean "occupied/free" flag — it is a number that encodes the exact generation of the slot. On construction seqBuf[i] = i, so slot 0 starts at 0, slot 1 at 1, and so on. After each write the stamp advances by 1. After each consume it advances by capacity. Because of this, slot 0's stamp trail across three laps looks like 0 → 1 → 4 → 5 → 8 → 9 → 12 → 13 … — it grows forever and never repeats, which is what prevents the ABA problem entirely.
expected (take only) = cIdx + 1
The producer, after winning its CAS and writing data, stamps the slot with pIdx + 1. So if a producer claimed slot 0 when pIdx was 4, it leaves seqBuf[0] = 5. The consumer that arrives with cIdx = 4 therefore looks for seqBuf[0] == cIdx + 1 == 5. The + 1 is the handshake signal: "a producer has finished writing here, and you are the right consumer to read it." The offer trace does not need an expected row because the producer compares seq directly against pIdx (not pIdx + 1) — the slot is free when the stamp equals the producer index exactly.
diff — the three-way decision
This is the key insight of the Vyukov algorithm. A single subtraction replaces what would otherwise be a tangle of conditional checks.
For offer: diff = seq − pIdx
diff == 0— the stamp exactly matches the producer index, meaning no one has touched this slot since it was last released. The slot is yours. The thread does a CAS onpIdx, writes the element, then stampsseqBuf[slot] = pIdx + 1.diff < 0— the stamp is behind the producer index. This means the slot is still occupied by data from the current lap that has not been consumed yet. The buffer is full. Returnfalse.diff > 0— the stamp is ahead of the producer index. Another producer already claimed this slot and advanced past it. Retry the loop with a fresh read ofpIdx.
For take: diff = seq − expected where expected = cIdx + 1
diff == 0— the stamp matches exactly what the producer left. Data is ready. CAS oncIdx, read the element, null out the slot for GC, stampseqBuf[slot] = cIdx + capacityto release the slot for a future producer on the next lap.diff < 0— the stamp is behind what the consumer expects, meaning the producer has not finished writing yet (or has not written at all). The buffer appears empty from this consumer's perspective. Returnnull.diff > 0— another consumer already read this slot and advanced past it. Retry.
Why all three decisions are safe without any lock
The diff check and the subsequent CAS form an atomic claim. Two producers might both read the same pIdx and both see diff == 0, but only one will win the CAS that advances pIdx. The loser sees the CAS fail, loops back, reads the new pIdx, and naturally ends up looking at the next slot. No explicit coordination between threads is ever needed — the sequence stamps and the monotonically increasing indices together make every slot's state self-describing at any point in time.
Creating Instances
MpmcRingBuffer[A] is the fully general-purpose implementation supporting any number of producers and consumers. It uses the Vyukov/Dmitry algorithm with a parallel sequence buffer to coordinate access safely.
object MpmcRingBuffer {
def apply[A <: AnyRef](capacity: Int): MpmcRingBuffer[A]
}
Creates a new MPMC ring buffer with the given capacity. The capacity must be a power of two >= 2 (the sequence buffer algorithm requires at least 2 slots). A capacity of 1 is valid for SpscRingBuffer, SpmcRingBuffer, and MpscRingBuffer, but MpmcRingBuffer needs at least 2 because its sequence stamp mechanism requires a second slot to distinguish the written-but-not-consumed state from the empty state.
We create an MPMC buffer as follows:
import zio.blocks.ringbuffer.MpmcRingBuffer
val rb = MpmcRingBuffer[String](128)
Operations
Inserting Elements — offer
MpmcRingBuffer#offer — Insert an element with this signature:
final class MpmcRingBuffer[A <: AnyRef](val capacity: Int) {
def offer(a: A): Boolean
}
Inserts the element without blocking. Returns true if inserted, false if the buffer is full. Throws NullPointerException if the element is null. Thread-safe; multiple producer threads may call this concurrently.
Removing Elements — take
MpmcRingBuffer#take — Remove an element with this signature:
final class MpmcRingBuffer[A <: AnyRef](val capacity: Int) {
def take(): A
}
Retrieves and removes an element from the front of the buffer. Returns immediately without blocking. Returns the element, or null if the buffer is empty. Thread-safe; multiple consumer threads may call this concurrently.
Checking State — size, isEmpty, isFull
All four implementations provide the same method signatures: size, isEmpty, and isFull. See the SpscRingBuffer Operations section for detailed descriptions and examples of these methods.
Example
MPMC: General-Purpose Queue
For workloads with multiple producers and consumers, use MpmcRingBuffer. This example demonstrates how multiple workers coordinate to process tasks from a shared queue.
import docs.SourceFile
SourceFile.print("zio-blocks-examples/src/main/scala/ringbuffer/MpmcExample.scala")
(source)
sbt "zio-blocks-examples/runMain ringbuffer.MpmcExample"
Thread Safety and Correctness
Ring buffers are lock-free but must be used correctly:
- Wrong thread access causes undefined behavior: Using
SpscRingBufferfrom multiple producer threads results in data races, silent data loss, or crashes. Always use the implementation matching your thread pattern. SpscRingBuffer#offerandSpscRingBuffer#takethread contract: The producer thread must be the sole caller ofoffer; the consumer thread must be the sole caller oftake. They may be the same physical thread (as in single-threaded environments like Scala.js or unit tests) or different threads.- State queries are approximate: Under concurrency,
SpscRingBuffer#size,SpscRingBuffer#isEmpty, andSpscRingBuffer#isFullmay be stale by the time they return. Do not rely on them for exact synchronization — useSpscRingBuffer#offer's return value for backpressure instead. - Null elements are forbidden: All implementations reject
nullwithNullPointerException. If you need to store nullable values, wrap them inOptionor another container.
Critical: Ring buffers do not enforce thread-safety at runtime. Using the wrong implementation for your thread pattern or calling methods from the wrong thread does not throw an exception — it silently corrupts data. Test thoroughly and document your threading contract.
Advanced Usage: Cache-Line Padding
Ring buffers use cache-line padding to prevent false sharing between producer and consumer indices on modern CPUs. The padding is transparent to users but enables dramatically lower latency on multi-core systems.
Each implementation pads its internal index fields (producer index, consumer index) to occupy a full cache line (128 bytes on Apple Silicon, 64 bytes on most other architectures). This ensures that when one thread reads its index, it does not invalidate the cache line holding the other thread's index, eliminating costly cache-coherency traffic.
This optimization is automatic and requires no configuration. Ring buffers are inherently more efficient than comparable Scala and Java implementations because of this padding.
Designing With Ring Buffers
Common patterns for using ring buffers include:
Pattern: Producer-Consumer Pipeline
Ring buffers form the backbone of producer-consumer pipelines where one or more producers generate work and one or more consumers process it:
┌──────────┐ ┌──────────────┐ ┌──────────┐
│Producer 1├──────>│ RingBuffer │<──────┤Consumer 1│
│Producer 2├──────>│ (MPMC, cap=N)│<──────┤Consumer 2│
└──────────┘ └──────────────┘ └──────────┘
In this pattern:
- Producers call
MpmcRingBuffer#offerand handle backpressure iffalseis returned (e.g., retry, queue internally, apply rate limiting). - Consumers call
MpmcRingBuffer#takein a tight loop, checking fornullto detect empty buffers. - Ring buffer capacity bounds memory and provides natural backpressure.
Pattern: Batch Processing
For workloads where producers batch elements together, use SpscRingBuffer#fill (SPSC) or SpscRingBuffer#offer in a loop:
Producer fills batch of N items
↓
Ring Buffer (growing)
↓
Consumer drain()s batch of M items
↓
Process batch
Batching reduces per-element synchronization costs.
Pattern: Work Stealing with Multiple Consumers (MPMC)
When multiple workers consume from the same queue, use MpmcRingBuffer. Each worker calls MpmcRingBuffer#take to grab the next item atomically:
import zio.blocks.ringbuffer.MpmcRingBuffer
case class Task(id: Int, work: String)
val queue = MpmcRingBuffer[Task](256)
def worker(): Unit = {
while (true) {
val task = queue.take()
if (task ne null) {
println(s"Processing: ${task.work}")
}
}
}
The CAS loop in MpmcRingBuffer#take ensures no two workers grab the same task.
Performance Characteristics
All ring buffer implementations provide O(1) time complexity for offer, take, size, isEmpty, and isFull operations.
- SPSC (FastFlow) — Fastest: avoids volatile reads on the fast path, minimal cache traffic
- SPMC — Fast: producer uses index-based checking; consumers CAS on a shared index
- MPSC — Fast: producers CAS on a shared index with a cached limit; consumer uses FastFlow relaxed-poll
- MPMC — Slightly slower: uses sequence buffer stamps for coordination; all indices use CAS
Actual performance depends on:
- CPU cache architecture — 64-byte vs 128-byte cache lines affect padding efficiency
- Contention level — high contention increases CAS failure rates and retries
- Element size — larger elements may affect cache locality
- Platform — JVM JIT warmup, Scala.js compiled code, GraalVM-generated native image
Micro-benchmark your specific workload if latency is critical.
Integration with Other ZIO Blocks Types
Ring buffers are standalone data structures and do not depend on other ZIO Blocks types. However, they integrate well with:
- Threading models: Ring buffers work on raw JVM threads, virtual threads (Loom), or platform-specific threads. Pair with
ZIO.forkorThreadas needed. - Reactive streams: Ring buffers can back reactive sources, where producers feed a
Sourceand consumers pull from it. The ring buffer provides natural backpressure viaoffer's return value. - Event loops: In game engines or event-driven systems, ring buffers connect event producers (input, network) to event dispatchers (main loop) with predictable latency.