The Disruptor Pattern: Lock-Free Ring Buffers for High-Throughput Event Processing
The Hook
Your message queue processes 100K msg/s. The LMAX Disruptor does 6 million msg/s on a single thread. It’s not magic — it’s mechanical sympathy: pre-allocated ring buffer, memory barriers instead of locks, and batch processing. Here’s the architecture.
The Thesis
Queues are the bottleneck. Locks, contention, cache misses, GC pressure. The Disruptor replaces the queue with a pre-allocated ring buffer + memory barriers + sequence barriers. It’s not a library — it’s a design pattern for mechanical sympathy.
The Science
The Queue Problem
| Queue Operation | Cost |
|---|---|
| Lock acquisition | 20-50ns (uncontended) → μs (contended) |
| Cache line bounce | 40-100ns per hop |
| CAS retry | Exponential backoff |
| GC pressure | Object allocation per message |
| False sharing | Adjacent fields on same cache line |
The Disruptor Architecture
Core Components:
- Ring Buffer — Pre-allocated array of events (power of 2 size)
- Sequences — Monotonically increasing counters (producer, consumers)
- Memory Barriers — Volatile/atomic operations instead of locks
- Sequence Barriers — Consumers wait on producer sequence
- Event Processors — Batch consumers
// Simplified Disruptor structure
public final class RingBuffer<E> {
private final Object[] entries; // Pre-allocated
private final long[] sequences; // Producer + consumer sequences
private final int mask; // size - 1 (power of 2)
public long next() {
long next = sequences[PRODUCER_INDEX] + 1;
// Wait for consumer to catch up (wrap check)
while (next - sequences[CONSUMER_INDEX] >= capacity) {
Thread.yield(); // or LockSupport.parkNanos(1)
}
sequences[PRODUCER_INDEX] = next;
return next;
}
public E get(long sequence) {
return (E) entries[(int) (sequence & mask)];
}
}
Mechanical Sympathy Principles
| Principle | Implementation |
|---|---|
| Pre-allocation | Zero GC during steady state |
| Single writer | No locks on write path |
| Cache line padding | @Contended / padded sequences |
| Batch processing | Process N events per loop |
| Busy spin | Thread.onSpinWait() vs park |
Performance Numbers (LMAX)
| Metric | Queue (ConcurrentLinkedQueue) | Disruptor |
|---|---|---|
| Throughput (1 thread) | 300K ops/s | 6M ops/s |
| Throughput (3 threads) | 500K ops/s | 20M ops/s |
| Latency (p99) | 500μs | 5μs |
| GC pressure | High | Zero (steady state) |
The Protocol: Implement a Minimal Disruptor
Core Data Structures
# Python-inspired minimal Disruptor (for understanding)
import threading
import time
from typing import Generic, TypeVar, Callable
T = TypeVar('T')
class Sequence:
def __init__(self, initial=0):
self._value = initial
self._lock = threading.Lock()
def get(self) -> int:
return self._value
def set(self, value: int):
self._value = value
def increment_and_get(self) -> int:
with self._lock:
self._value += 1
return self._value
class RingBuffer(Generic[T]):
def __init__(self, capacity: int, event_factory: Callable[[], T]):
# Power of 2
assert (capacity & (capacity - 1)) == 0
self.capacity = capacity
self.mask = capacity - 1
self.entries = [event_factory() for _ in range(capacity)]
self.producer_sequence = Sequence(-1)
self.consumer_sequences = [] # List[Sequence]
def next(self) -> int:
next_seq = self.producer_sequence.increment_and_get()
# Wait for slowest consumer
while True:
min_consumer = min(s.get() for s in self.consumer_sequences)
if next_seq - min_consumer < self.capacity:
break
time.sleep(0.000001) # busy spin
return next_seq
def get(self, sequence: int) -> T:
return self.entries[sequence & self.mask]
def publish(self, sequence: int):
# Memory barrier - ensure writes visible
pass # In Java: VarHandle.releaseFence()
class EventProcessor:
def __init__(self, ring_buffer: RingBuffer, handler: Callable):
self.sequence = Sequence(-1)
ring_buffer.consumer_sequences.append(self.sequence)
self.handler = handler
self.running = False
def run(self):
self.running = True
while self.running:
next_seq = self.sequence.get() + 1
# Wait for producer
while self.ring_buffer.producer_sequence.get() < next_seq:
time.sleep(0.000001)
event = self.ring_buffer.get(next_seq)
self.handler(event)
self.sequence.set(next_seq)
Usage Pattern
# 1. Define event
class TradeEvent:
def __init__(self):
self.symbol = ""
self.price = 0.0
self.quantity = 0
self.timestamp = 0
# 2. Create disruptor
ring_buffer = RingBuffer(1024 * 1024, TradeEvent) # 1M events
# 2. Create handlers
def risk_check(event: TradeEvent):
if event.quantity > 10000:
alert_risk_team(event)
def persist(event: TradeEvent):
db.write(event)
def audit_log(event: TradeEvent):
audit.write(event)
# 3. Chain processors (pipeline)
risk_processor = EventProcessor(ring_buffer, risk_check)
persist_processor = EventProcessor(ring_buffer, persist)
audit_processor = EventProcessor(ring_buffer, audit_log)
# 4. Start processors
for p in [risk_processor, persist_processor, audit_processor]:
threading.Thread(target=p.run, daemon=True).start()
# 5. Publish (single writer)
def publish_trade(symbol, price, qty):
seq = ring_buffer.next()
event = ring_buffer.get(seq)
event.symbol = symbol
event.price = price
event.quantity = qty
event.timestamp = time.time_ns()
ring_buffer.publish(seq)
The Counterpoint
- Single writer: Hard to scale producers (use multiple ring buffers)
- Complexity: More code than
queue.put() - Debugging: Harder to inspect than queue
- Python GIL: Python implementation limited by GIL (use Java/Rust/Go)
Practical Takeaway
Use Disruptor when: >100K msg/s, latency <1ms p99, single writer, JVM/Go/Rust. Stick with queues when: <10K msg/s, multiple producers, Python, simplicity matters.
Start here: Read LMAX Disruptor paper. Implement minimal version in your language. Benchmark vs queue. The 100x throughput difference is real.
Sources: LMAX Disruptor white paper (2011); Thompson et al. “The LMAX Architecture”; Martin Fowler “The LMAX Architecture”; Disruptor GitHub; Mechanical Sympathy blog (Martin Thompson); “Memory Barriers” (Dmitry Vyukov); Herb Sutter “Lock-Free Code”.