The ring buffer, explained

How Prayog turns thousands of orders arriving at once from many places into one official, fair, replayable order of events, without a single lock. First a business example, then how it works, then exactly how Prayog uses it.

1. The problem it solves

Orders reach the exchange from many directions at the same instant: hundreds of bots, a few humans in the web terminal, the clock ticking every 100 ms, an ops person pressing "halt". Each of these runs on its own thread.

But the order book must be changed by one thread only:

So we need something at the entrance that takes many writers and hands one reader a single, numbered line of work. That's the ring buffer. Prayog uses the LMAX Disruptor, a widely used Java implementation built by the LMAX exchange for exactly this.

2. A business example: the restaurant ticket wheel

Picture a busy restaurant kitchen with a round ticket wheel: a ring of 8 numbered clips that rotates in front of the head chef.

Waiter 1 Waiter 2 Waiter 3 many writers at once Ticket wheel 8 numbered clips, reused Head chefcooks tickets strictly in clip order Expediterchecks each plate after the chef Runnerserves after the expediter A clip is freed only after the runner is done with it. All 8 clips busy? Waiters must wait, or tell guests "the kitchen is full".
In the kitchenWhat it meansIn Prayog
Waiters clip tickets on the next free clipMany writers; each grabs the next position, and two waiters never get the same clipGateway threads, the clock, ops call submit / trySubmit
Clips are numbered and reused: ticket 9 goes on clip 1 againA fixed number of slots; ticket n uses clip n mod 865,536 pre-allocated CommandSlots
The head chef cooks strictly in clip orderOne reader decides the official order of workThe matching thread applies each command to the engine and numbers it
The expediter checks a plate only after the chef has cooked itA second reader follows behind the first and never overtakes itThe journal writes each command and its events to disk after matching (S8)
The runner serves only checked platesA third reader follows the secondPublishers send responses and market data only after the journal (durable first, announce second)
A clip is freed once the runner is doneThe slowest reader decides when a slot can be reusedA slot is overwritten only after every stage has passed it
All clips full → "kitchen is full, please wait"Bounded capacity: overload is refused, not hiddentrySubmit returns false → gateway answers "busy" (back-pressure)

Notice what the kitchen doesn't have: no manager taking a lock on the whole wheel each time a waiter clips a ticket, and no new clips being made for every order. That's exactly why the design is fast.

3. Why not a normal queue, or locks?

ApproachHow it worksCost per orderProblems
Lock the order bookEvery thread takes a lock, changes the book, releasesA lock acquire + release; threads queue for it under loadSlow under contention; arrival order depends on who wins the lock race, so there is no clean recorded order
LinkedBlockingQueueWriters put, one reader takesA new node object per message + a lock on put and takeGarbage for the collector to clean; lock contention; no built-in way to chain later stages
ArrayBlockingQueueFixed array, but one lock for both endsA lock per operationContention between writers and the reader; still one consumer stage
Ring buffer (Disruptor)Fixed array of reusable slots; writers claim positions with an atomic counter; readers follow with their own countersA few atomic operations; no lock, no new objectsMore concepts to learn; capacity is fixed up front (by design)

LMAX publicly reported the Disruptor handling millions of messages per second on one thread with much lower and steadier latency than queues. Prayog doesn't need that speed. We use it because it gives us one official order, pipeline stages and back-pressure in one well-tested piece.

4. How the ring works

Sequence numbers, not positions

Everything is counted with ever-increasing sequence numbers (1, 2, 3, …, millions). They never wrap. The slot for sequence s is s mod size. Because the size is a power of two (8, 1,024, 65,536), "mod" is a single bit operation: s & (size − 1). That's why the ring size must be a power of two.

The producer may claim up to (slowest reader + 8) = 18. Claiming 19 would overwrite slot 3 while publishers still need sequence 11 there.

Writing (producers)

  1. Claim. A producer atomically increases the shared "claimed" counter (a compare-and-swap). Two threads can never get the same sequence. Before claiming, it checks that the slot is free: claim − slowest reader ≤ size. If not, trySubmit gives up and returns false, while submit waits.
  2. Write. It copies the command into that slot. The slot object already exists, created once at startup, so nothing is allocated.
  3. Publish. It marks the sequence as available. Readers can now see it. Publishing uses a memory barrier so other threads see the fully written slot, never a half-written one.

Reading (consumers)

  1. Each reader keeps its own counter, "the last sequence I finished". The matching handler waits for producers; the journal waits for matching; publishers wait for the journal. These dependencies form the pipeline.
  2. A reader that has nothing to do waits using its wait strategy: sleep until woken (BLOCKING, low CPU), spin then yield (YIELDING), or spin forever on its own core (BUSY_SPIN, lowest latency).
  3. When a reader falls behind, it finds many ready slots and processes them as a batch, without waiting in between. The last one is flagged endOfBatch. The journal uses that flag to flush the disk once for the whole batch (group commit), so a slow disk gets more efficient under load, not less.

Why it's fast (the short version)

5. Try it: a ring of 8 slots

Submit commands, then advance each stage. Watch the colours move round the ring, and try filling it to see back-pressure. Sequence numbers keep growing while slots are reused.

free written, waiting for matching matched journaled
Producers (many threads)
Consumers (one thread each, in order)
Play

Things to try: press "Burst of 5" twice: the second burst is refused partway, which is back-pressure. Then advance only Matching: it can run ahead, but Publishers can't pass the Journal. Turn on "Slow disk" in auto-run and watch the ring fill up, then drain in batches.

6. The ring in Prayog

Gateway threads ClockTicker Ops trySubmit / submit Disruptor ring65,536 CommandSlots MatchingHandlerinputSeq = ++nengine.apply(command) Journalstage 1 · S8 Responsesstage 2 Market datastage 2 Red = the hot path (one thread, no I/O). Stages run strictly in order; handlers inside one stage run in parallel on their own threads.
PieceFileJob
ExchangePipelineexchange-core/…/pipeline/ExchangePipeline.javaBuilds the Disruptor (ProducerType.MULTI, ring size and wait strategy from PipelineConfig), wires the stages, exposes submit and trySubmit
CommandSlot…/pipeline/CommandSlot.javaOne reusable slot: the command, its input sequence and the events it produced
MatchingHandlerinside ExchangePipelineThe single writer: clears the slot's events, sets inputSeq = ++n, calls engine.apply; the engine's events land in the slot
PipelineHandler…/pipeline/PipelineHandler.javaOur interface for later stages (journal, publishers); gets endOfBatch for group commit
WaitStrategyType…/pipeline/WaitStrategyType.javaBLOCKING (default), YIELDING, BUSY_SPIN
SimClock, ClockTicker…/pipeline/Wall time → sim time; a tick into the ring every 100 ms

Key choices (ADR 0006)

7. One order, end to end

A bot sends BUY 10 ABC @ ₹100.05 while the clock thread is publishing a tick at the same moment.

  1. The gateway thread calls trySubmit(newOrder). It claims sequence 41,203, which maps to slot 41,203 & 65,535 = 41,203, writes the command reference and publishes. Meanwhile the clock thread claims 41,204 for its tick. Neither waited for the other.
  2. The matching thread, which has been waiting, sees 41,203 is available. It sets inputSeq to the next number (say 41,203), applies the order: accepted, one trade at ₹100.00. It writes two events into the slot's list.
  3. It moves straight on to 41,204 (the tick) in the same batch, with no waiting between them.
  4. The journal stage (S8) sees both, writes the two commands and their events, and calls fsync once because 41,204 is the end of the batch.
  5. The publisher stages send the HTTP response to the bot and push the trade print and book update to WebSocket subscribers.
  6. Once every stage has passed 41,203, its slot is free and will be reused by sequence 106,739 (41,203 + 65,536).

What the S7 test proves: 8 threads × 2,000 commands at once. Whatever order the ring produced, the input sequence has no gaps, each thread's own order is preserved, and replaying the recorded order through a fresh engine gives identical events. S8 makes that recorded order permanent, on disk.

8. Questions

What happens if matching is slower than producers?

The ring fills. trySubmit starts returning false, and the gateway answers 503 "busy". Clients back off, and nothing is lost, because nothing was accepted.

What if the journal (disk) is slow?

Matching can run ahead, but only until the ring is full, because slots aren't freed until the journal and the publishers pass them. Under load the journal sees bigger batches, so each fsync covers more commands.

Isn't one matching thread a bottleneck?

One thread can match millions of simple orders per second because it never waits for locks or I/O. When that's not enough, exchanges shard: symbols A–M on one ring and thread, N–Z on another. Prayog runs one shard for the MVP.

Why are the producers allowed to block in submit?

Only internal producers use it: a lost clock tick or a lost "halt" would be worse than a few milliseconds' wait. The gateway uses trySubmit so web threads never hang.

Do commands still allocate memory?

Yes. Our commands are Java records created by producers; only the slots are pre-allocated. Fully garbage-free designs write raw bytes into the slots. We'll consider that only if the S9 benchmarks show garbage collection hurting latency.

9. Glossary

TermMeaning
Ring bufferA fixed-size array used in a circle: position = sequence mod size
SequenceAn ever-increasing counter (1, 2, 3, …). Producers claim them; each consumer remembers the last one it finished
Producer / consumerWrites into the ring / reads from it
Single writerOnly one thread changes a piece of state; here, the order books
GatingA consumer may not pass the one it depends on; producers may not lap the slowest consumer
Back-pressureRefusing new work when full, instead of queueing without limit
Wait strategyHow an idle consumer waits: block, yield or busy-spin
Batching / group commitProcessing many ready slots in one go; one fsync for the whole batch
Compare-and-swap (CAS)A CPU instruction that updates a value only if it still has the expected old value. Lets threads claim sequences without a lock
Memory barrierMakes writes by one thread visible to others in the right order, so readers never see a half-written slot
False sharingTwo threads' counters on the same CPU cache line slow each other down; the Disruptor pads them apart