Observer — Professional Level¶
Source: refactoring.guru/design-patterns/observer Prerequisite: Senior
Table of Contents¶
- Introduction
- LMAX Disruptor Internals
- Reactive Streams Spec
- Concurrency Internals — CopyOnWriteArrayList
- Memory Model and Visibility
- Async Bus Performance
- Backpressure Strategies in Practice
- Kafka Consumer Performance
- Hot vs Cold Observables
- Cross-Language Comparison
- Microbenchmark Anatomy
- Diagrams
- Related Topics
Introduction¶
An Observer at the professional level is examined for what the runtime does with it: how lock-free subscriber lists work, how reactive streams enforce backpressure, how the Disruptor achieves million-events-per-second throughput, and where the inevitable performance cliffs are.
For high-throughput systems — trading engines, real-time analytics, telemetry pipelines — the Observer machinery itself is the system's spine. This document quantifies it.
LMAX Disruptor Internals¶
The LMAX Disruptor is a high-performance Observer-like dispatcher used in financial trading. Key insights:
Ring buffer instead of queue¶
A bounded array of slots; producers and consumers track sequences. No allocation on the hot path.
Mechanical sympathy¶
- Power-of-two size → bitmask
idx & (size-1)instead of modulo. - Cache-line padding around volatile fields → no false sharing.
- Single-writer principle → producers serialize into the ring; consumers read.
- Multiple consumers track independent sequences; the slowest blocks the producer (backpressure built-in).
Throughput¶
LMAX reported 6 million ops/sec on commodity hardware in 2010. Modern Disruptor implementations exceed 100M events/sec for simple events.
Trade-off¶
Bounded — producer must block or drop when consumers lag. Designed for known-bounded latency systems, not arbitrary fan-out.
Reactive Streams Spec¶
The Reactive Streams JVM spec (adopted by Reactor, RxJava 2+, Akka) standardizes async Observer with backpressure. Key contract:
interface Publisher<T> {
void subscribe(Subscriber<? super T> s);
}
interface Subscriber<T> {
void onSubscribe(Subscription s);
void onNext(T t);
void onError(Throwable t);
void onComplete();
}
interface Subscription {
void request(long n);
void cancel();
}
Backpressure in action¶
The subscriber requests n items: subscription.request(n). The publisher must NOT emit more than n until the subscriber requests more.
flux.subscribe(new BaseSubscriber<Integer>() {
@Override protected void hookOnSubscribe(Subscription s) { s.request(10); }
@Override protected void hookOnNext(Integer i) {
process(i);
request(1); // ask for one more
}
});
Why this matters¶
Without backpressure, a fast publisher and slow subscriber overflow memory. Reactive Streams makes the contract explicit and verifiable (TCK).
Operators inherit the contract¶
map, filter, flatMap all propagate backpressure. flatMap can introduce concurrency that subverts strict order — designers must understand this.
Concurrency Internals — CopyOnWriteArrayList¶
Java's go-to subscriber list:
public boolean add(E e) {
synchronized (lock) {
Object[] elements = getArray();
Object[] newElements = Arrays.copyOf(elements, elements.length + 1);
newElements[elements.length] = e;
setArray(newElements);
return true;
}
}
public Iterator<E> iterator() {
return new COWIterator<>(getArray(), 0);
}
Reads are lock-free¶
getArray() returns the current snapshot. Iterators are over the snapshot — never see in-flight mutations.
Writes are O(n)¶
Each add allocates a new array and copies. For large subscriber sets that change often, expensive. For "read-mostly" subscriber lists (typical), perfect.
Iteration during modification¶
for (Listener l : listeners) { // safe; iterator on old snapshot
if (someCondition) listeners.add(newListener); // mutates underlying; iterator unaffected
}
The iterator never throws ConcurrentModificationException. New subscribers added during iteration are not seen by that iteration — they appear in subsequent publishes.
Memory cost¶
Doubles memory on each write (old + new array briefly). For thousands of subscribers, careful.
Memory Model and Visibility¶
class Bus {
private List<Listener> listeners = new ArrayList<>(); // not volatile
public synchronized void subscribe(Listener l) { listeners.add(l); }
public void publish(Event e) {
for (Listener l : listeners) l.on(e); // visibility hazard
}
}
The hazard¶
Without synchronization on the publish path, the Java Memory Model doesn't guarantee that a subscription added by thread T1 is visible to thread T2 publishing.
Fix 1: synchronize publish¶
But now publishes serialize. Slow.
Fix 2: volatile + immutable list¶
private volatile List<Listener> listeners = List.of();
public void subscribe(Listener l) {
synchronized (this) {
var copy = new ArrayList<>(listeners);
copy.add(l);
listeners = List.copyOf(copy); // publish under volatile write
}
}
public void publish(Event e) {
for (Listener l : listeners) l.on(e); // volatile read → visibility OK
}
Fix 3: CopyOnWriteArrayList¶
Effectively the same as Fix 2 with library help. Recommended.
Async Bus Performance¶
Single-threaded executor (ordering preserved)¶
ExecutorService exec = Executors.newSingleThreadExecutor();
public void publish(Event e) {
for (Listener l : listeners) exec.submit(() -> l.on(e));
}
Throughput limited to one core's processing. Ordering preserved across handlers and events.
Fixed thread pool (parallel)¶
Parallelism N. Ordering across handlers lost. Per-handler ordering depends on whether the same handler always lands on the same thread (it doesn't, by default).
Per-handler executor (per-handler ordering)¶
Map<Listener, ExecutorService> execs = new ConcurrentHashMap<>();
public void subscribe(Listener l) {
listeners.add(l);
execs.put(l, Executors.newSingleThreadExecutor());
}
public void publish(Event e) {
for (Listener l : listeners) execs.get(l).submit(() -> l.on(e));
}
Each handler sees events in publish order. Different handlers can interleave. Common pattern.
Latency vs throughput¶
Sync: low latency for first observer, total latency = sum. Async (per-handler): higher latency for any one observer, total latency = max.
Async wins when observer count is high and observers are heavy.
Backpressure Strategies in Practice¶
Lossy: drop newest¶
BlockingQueue<Event> q = new ArrayBlockingQueue<>(1000);
public void publish(Event e) {
if (!q.offer(e)) {
droppedCounter.increment(); // metric
// newest dropped
}
}
When latest matters most (sensors, status updates), often acceptable.
Lossy: drop oldest¶
Reverse: keep newest, drop the oldest unprocessed.
For sliding-window analytics.
Blocking: backpressure publisher¶
Publisher slows naturally. Fine for in-process; awkward for external producers.
Spill to disk¶
Kafka, Pulsar, NATS Jetstream. Memory bounded; disk durable. The right answer when throughput matters.
Reactive Streams request(n)¶
Pull-based: subscribers tell publishers their capacity. Operators (Reactor, RxJava) propagate request upstream.
Kafka Consumer Performance¶
Single-partition consumer¶
One partition → one consumer thread. Throughput bounded by handler latency.
If handlers do I/O, throughput is 1 / latency.
Multi-partition / consumer group¶
Topic partitions: [p0, p1, p2, p3]
Consumer group: [c0, c1, c2, c3]
Each consumer owns one partition.
Throughput scales linearly with partitions. Limit: number of partitions (typically 12-100 per topic).
Idempotent consumers¶
Kafka delivers at-least-once. Consumer must handle duplicates. Common: processed_ids table or idempotency key.
Commit strategies¶
- Auto-commit: simple, but can lose events on rebalance.
- Manual commit per record: durable, but latency overhead.
- Manual commit per batch: balance.
Most production: manual commit at end of batch processing.
Backpressure¶
Consumer naturally backpressures: if poll is slow, the broker's offset advances slower; producer is unaffected (except topic disk usage).
Hot vs Cold Observables¶
Hot¶
Emits whether or not anyone subscribes. Live events: clicks, sensor readings, market ticks.
PublishSubject<Event> hot = PublishSubject.create();
hot.onNext(e1); // emitted; no subscriber sees it
hot.subscribe(...); // subscribes from this point on
Late subscribers miss prior events.
Cold¶
Emits only when subscribed. Each subscriber gets a fresh stream.
Observable<Integer> cold = Observable.create(emitter -> {
for (int i = 0; i < 10; i++) emitter.onNext(i);
});
Each subscribe triggers a fresh emission of 0..9.
Implications¶
- Hot + many subscribers: same data delivered to all.
- Cold + many subscribers: data computed N times. Use
share()orpublish().refCount()to convert to hot.
Replay¶
Buffered hot:
Late subscribers get the last 100. Memory cost vs late-arrival semantics.
Cross-Language Comparison¶
| Language | Primary Observer Mechanism | Notes |
|---|---|---|
| Java/JVM | EventBus, Reactor, RxJava | CopyOnWriteArrayList common; reactive-streams TCK |
| Kotlin | Coroutines Flow, SharedFlow | Cold (Flow) and hot (SharedFlow) variants |
| C# | event keyword, IObservable, Rx.NET | Built-in language feature |
| Go | Channels (close + range) | Idiomatic Observer is chan T; closed channel signals end |
| Rust | tokio::sync::broadcast, crossbeam | Bounded broadcast channel |
| Python | asyncio.Queue, blinker, RxPY | Multiple ecosystem options |
| JavaScript | EventEmitter, RxJS, Observable proposal | DOM events; pervasive |
| Swift | Combine framework, Notifications | Native reactive |
Key contrasts¶
- Channels (Go) vs callbacks: channels are unidirectional; receiver pulls. Backpressure is implicit (full channel blocks sender).
- C#
eventvs reactive:eventis a multicast delegate; lighter than full reactive but no backpressure. - Coroutines
Flowvs RxJava:Flowintegrates with structured concurrency; cancellation is automatic with the coroutine scope.
Microbenchmark Anatomy¶
Simple sync bus¶
@State(Scope.Benchmark)
public class BusBench {
Bus bus = new Bus();
@Setup public void setup() {
for (int i = 0; i < 10; i++) bus.subscribe(e -> {});
}
@Benchmark public void publish() { bus.publish(new Event()); }
}
Typical numbers (JDK 21, x86): - Empty observer: ~50 ns total for 10 observers (5 ns each — vtable + tiny body). - Observer doing trivial work: dominated by the work, not dispatch.
Async bus with single thread¶
Same structure, with executor. Throughput limited by the single thread; latency per publish: ~µs (thread handoff).
Disruptor¶
~100 ns per event with proper setup. Can saturate a CPU core at 10M events/sec.
JMH pitfalls¶
- Forgetting
@State→ benchmarks recreate state each iteration. - Constant inputs → JIT folds.
- No
Blackhole.consume()→ DCE removes the work.
Diagrams¶
Reactive Streams contract¶
Disruptor¶
Related Topics¶
- Reactive streams spec
- Disruptor architecture
- Kafka consumer tuning
- Backpressure patterns
- Memory model — happens-before