Skip to main content
Version: 2.x

ZIO Blocks — Ring Buffer (zio.blocks.ringbuffer)

zio.blocks.ringbuffer is a family of high-performance, bounded ring buffers for the JVM (and Scala.js). Each variant is optimized for a specific producer/consumer threading pattern—pick the one that matches your use case and get the fastest possible inter-thread communication with zero dependencies.

All variants expose offer (returns false if full) and take (returns null if empty)—both non-blocking. Capacity must be a power of two (enables bitwise masking instead of modulo). Elements must be non-null reference types (A <: AnyRef).

Ring buffer variants

TypeProducersConsumersAlgorithm
SpscRingBuffer11FastFlow (null/non-null signaling)
SpmcRingBuffer1ManyIndex-based capacity + CAS consumers
MpscRingBufferMany1CAS producers + relaxed poll
MpmcRingBufferManyManyVyukov/Dmitry sequence buffer

Naming convention: S = single, M = multi, p = producer, c = consumer.


Installation

libraryDependencies += "dev.zio" %% "zio-blocks-ringbuffer" % "0.0.29"

Quick start

import zio.blocks.ringbuffer.SpscRingBuffer

val buf = SpscRingBuffer[String](1024) // capacity must be a power of 2

// Producer thread
buf.offer("hello") // true if inserted, false if full

// Consumer thread
val msg: String = buf.take() // element or null if empty

API

Every ring buffer provides:

def offer(a: A): Boolean   // insert; returns false if full
def take(): A // remove; returns null if empty
def size: Int // approximate element count
def isEmpty: Boolean // approximate emptiness check
def isFull: Boolean // approximate fullness check

SpscRingBuffer additionally provides batch operations:

def drain(consumer: A => Unit, limit: Int): Int  // drain up to limit elements
def fill(supplier: () => A, limit: Int): Int // fill up to limit slots

All size/isEmpty/isFull values are approximate under concurrency—they are snapshots that may be stale by the time the caller acts on them.


Choosing a variant

Use the most constrained variant that fits your threading model:

ScenarioRecommended
Dedicated pipeline: one writer thread, one reader threadSpscRingBuffer
Fan-in: many writers, one reader (e.g., logging, event aggregation)MpscRingBuffer
Fan-out: one writer, many readers (e.g., work distribution)SpmcRingBuffer
General purpose: any number of writers and readersMpmcRingBuffer

Usage examples

SPSC with drain/fill

import zio.blocks.ringbuffer.SpscRingBuffer

val buf = SpscRingBuffer[java.lang.Integer](64)

// Producer: batch-fill from a data source
var seq = 0
val filled = buf.fill(() => { seq += 1; Integer.valueOf(seq) }, 32)
println(s"Filled $filled elements")

// Consumer: batch-drain into a processor
val drained = buf.drain(e => println(s"Processing $e"), 32)
println(s"Drained $drained elements")

MPSC fan-in (multiple producers, single consumer)

import zio.blocks.ringbuffer.MpscRingBuffer

val buf = MpscRingBuffer[String](256)

// Multiple producer threads
for (i <- 0 until 4) {
new Thread(() => {
for (j <- 0 until 100)
buf.offer(s"producer-$i: message-$j")
}).start()
}

// Single consumer thread
new Thread(() => {
var msg = buf.take()
while (msg != null) {
println(msg)
msg = buf.take()
}
}).start()

SPMC fan-out (single producer, multiple consumers)

import zio.blocks.ringbuffer.SpmcRingBuffer

val buf = SpmcRingBuffer[String](256)

// Single producer thread
new Thread(() => {
for (i <- 0 until 400)
while (!buf.offer(s"task-$i")) {} // retry if full
}).start()

// Multiple consumer (worker) threads
for (w <- 0 until 4) {
new Thread(() => {
var msg = buf.take()
while (msg != null) {
println(s"worker-$w: $msg")
msg = buf.take()
}
}).start()
}

Non-blocking try-once with fallback

import zio.blocks.ringbuffer.MpmcRingBuffer

val buf = MpmcRingBuffer[String](64)

// Try to offer without blocking; handle backpressure yourself
if (!buf.offer("data")) {
// buffer is full — drop, log, or retry later
println("Buffer full, applying backpressure")
}

// Try to take without blocking
val result = buf.take()
if (result != null) {
println(s"Got: $result")
} else {
// buffer is empty — do other work
}

Design notes

FastFlow pattern (SPSC)

SpscRingBuffer uses the FastFlow algorithm: the null/non-null state of an array slot is the synchronization signal. The producer never reads consumerIndex; the consumer never reads producerIndex. This minimizes cross-core cache traffic to a single cache line per operation.

A look-ahead step (capacity/4, capped at 4096) lets the producer batch-check multiple future slots at once, further reducing the frequency of slow-path reads.

Vyukov/Dmitry sequence buffer (MPMC)

MpmcRingBuffer uses a parallel Long sequence buffer alongside the data array. Each slot carries a stamp indicating whether it is available for writing or reading. Both producerIndex and consumerIndex are advanced via CAS, allowing any number of threads on both sides. The minimum capacity is 2 (the algorithm requires at least 2 slots to distinguish written from consumed).

CAS-based producers (MPSC)

MpscRingBuffer follows the JCTools MpscArrayQueue design: producers claim a slot via CAS on producerIndex, then write the element with release semantics. A cached producerLimit avoids reading consumerIndex on every offer, reducing cross-core traffic. The consumer side uses relaxed poll semantics—a null slot means either empty or a producer mid-write.

Index-based SPMC

SpmcRingBuffer uses index-based capacity checking on the producer side (no CAS needed for a single producer). Consumers use a CAS loop on consumerIndex. Consumers read the element before the CAS to avoid a race with the producer overwriting the slot. Consumers do not null array slots after reading—the producer overwrites them on the next lap.

Cache-line padding

All ring buffers use 128-byte padding regions (16 Long fields) between producer and consumer fields. This prevents false sharing on all architectures, including Apple Silicon which uses 128-byte cache lines (most x86 CPUs use 64-byte lines).

The padding is implemented via a class hierarchy:

Pad0 → ProducerFields → Pad1 → ConsumerFields → Pad2

VarHandle for memory ordering

All JVM implementations use java.lang.invoke.VarHandle (Java 9+) for acquire/release semantics instead of sun.misc.Unsafe. This is the recommended modern approach for lock-free data structures on the JVM.

Power-of-two masking

Capacity must be a power of two. This allows index & (capacity - 1) instead of index % capacity, which is significantly faster because bitwise AND compiles to a single CPU instruction.


Thread-safety contract

Violating the threading contract (e.g., calling take from multiple threads on an SpscRingBuffer) results in undefined behavior. No runtime check is performed—this is enforced by contract for maximum performance.

Typeoffertake
SpscRingBufferSingle producer thread onlySingle consumer thread only
SpmcRingBufferSingle producer thread onlyAny number of consumer threads
MpscRingBufferAny number of producer threadsSingle consumer thread only
MpmcRingBufferAny number of producer threadsAny number of consumer threads

Performance characteristics

OperationComplexity
offerLock-free (SPSC/SPMC: wait-free)
takeLock-free (SPSC/MPSC: wait-free)

SPSC is the fastest: no CAS, no locks, minimal cache-line traffic. Use it whenever your threading model allows a dedicated producer-consumer pair.


Cross-platform support

On Scala.js, all ring buffer types compile and provide the same API surface. Since Scala.js is single-threaded, the JS implementations use plain reads and writes with no memory ordering primitives.

PlatformSupport
JVMFull concurrency support
Scala.jsSequential (same API)