Quant developer HFT course · Module 4: Concurrency
The single-producer single-consumer ring buffer
The question
Write a queue to pass market-data updates from the feed thread to the strategy thread with as little latency as possible. Exactly one thread pushes and exactly one pops.
Why one producer and one consumer makes it simple
Most of the difficulty in concurrent queues comes from several threads competing to update the same index. With exactly one producer and one consumer, there is no competition: the producer is the only thread that writes the tail, the consumer the only one that writes the head. Each thread reads the other's index to know how far it may go. No compare-and-swap, no locks, no retries.
A ring of eight slots. Step through a burst of pushes and pops.
-
Step 1 / 6
Empty: head and tail are both 0. head == tail means there is nothing to read.
-
Step 2 / 6
The producer writes q0, q1, q2 into slots 0 to 2, then publishes tail = 3 with a release store.
-
Step 3 / 6
The consumer reads slots 0 and 1, then publishes head = 2. Those two slots may now be reused.
-
Step 4 / 6
Five more quotes: tail is now 8, which the mask 8 − 1 maps back to slot 0. The indices keep counting; only the slot wraps.
-
Step 5 / 6
Two more fill slots 0 and 1: tail = 10, head = 2, and 10 − 2 = 8 = capacity. Full: the next push fails until the consumer moves.
-
Step 6 / 6
The consumer reads q2, q3, q4 and publishes head = 5. tail − head = 5 quotes are still waiting, in order.
The structure
- A fixed array of slots, allocated once, with a capacity that is a power of two so that wrapping an index is a mask instead of a division.
tail: the next slot the producer will write. Written only by the producer.head: the next slot the consumer will read. Written only by the consumer.- The queue is empty when
head == tailand full whentail - head == capacity. Using indices that increase for ever and masking them on access (rather than wrapping the indices themselves) makes that distinction free; 64-bit indices will not overflow in the life of the process.
template <class T, std::size_t N>
class SpscRing {
static_assert((N & (N - 1)) == 0, "capacity must be a power of two");
std::array<T, N> slots_;
alignas(64) std::atomic<std::uint64_t> tail_{0}; // producer writes
alignas(64) std::atomic<std::uint64_t> head_{0}; // consumer writes
public:
bool push(const T& v) {
const auto t = tail_.load(std::memory_order_relaxed); // our own index
if (t - head_.load(std::memory_order_acquire) == N) return false; // full
slots_[t & (N - 1)] = v;
tail_.store(t + 1, std::memory_order_release); // publish the slot
return true;
}
bool pop(T& out) {
const auto h = head_.load(std::memory_order_relaxed); // our own index
if (h == tail_.load(std::memory_order_acquire)) return false; // empty
out = slots_[h & (N - 1)];
head_.store(h + 1, std::memory_order_release); // free the slot
return true;
}
};Why each ordering is what it is
- The producer reads its own
tail_relaxed: no other thread writes it. - The producer's release store of
tail_publishes the slot it just wrote. The consumer's acquire load oftail_pairs with it, so when the consumer sees the new tail, the slot's contents are visible. This is the publication pattern from the previous lesson. - The consumer's release store of
head_says "I have finished reading this slot". The producer's acquire load ofhead_pairs with it, so the producer never overwrites a slot the consumer is still reading.
Making it fast
Separate cache lines. head_ and tail_ are written by different threads. If they shared a 64-byte line, every push would invalidate the consumer's copy of the line and every pop the producer's, even though neither thread writes the other's variable. That is false sharing (Module 5). alignas(64) puts each on its own line. Some implementations use 128 bytes because some Intel prefetchers pull in adjacent pairs of lines; std::hardware_destructive_interference_size names the value where the library provides it.
Cache the other side's index. Each push loads head_, a line the consumer keeps writing, so the load usually misses. But the producer only needs the exact head when the queue looks full. Keep a private copy and refresh it only then:
// producer side, inside push:
if (t - head_cache_ == N) { // looks full: refresh
head_cache_ = head_.load(std::memory_order_acquire);
if (t - head_cache_ == N) return false; // really full
}
The consumer does the same with a cached tail. Now in the common case each side touches only its own lines, and the shared lines move between cores only when the other side has made progress. This one change is often worth more than everything else.
Batch. A consumer that drains several items and then publishes the head once, or a producer that publishes the tail once per burst, moves the shared lines less often.
Avoid copying large items. For big messages, write them in place: T* claim() returns the next slot for the producer to fill, commit() publishes it. The consumer reads in place in the same way.
What happens when it is full
The producer has three options and the right one depends on what it is:
- Return false and let the caller decide, as above. A strategy sending orders should know.
- Drop and count. A market-data producer that cannot slow the exchange down may drop and record a gap, forcing a resynchronisation (Module 7).
- Overwrite the oldest, for data where only the latest matters, which turns the queue into a different structure with its own synchronisation.
Spinning until there is space is wrong for a feed handler: the network will not wait.
Check yourself
Question 1 of 3
Why does an SPSC ring need no compare-and-swap?
Compare-and-swap resolves competing writers. With one writer per index there is no competition, only publication, which release and acquire handle.
Question 2 of 3
Why are head_ and tail_ placed on separate cache lines?
Sharing a line would bounce it between the two cores on every operation even though each thread writes only its own variable.
Question 3 of 3
What does caching the consumer's head on the producer side achieve?
Most pushes then touch only lines the producer owns. The cached value can only be stale in the safe direction: the queue is at least as empty as it appears.
In the interview
Say your own answer out loud first, then open the one we would give.
How does a lock-free SPSC queue work?
A preallocated ring of slots with a power-of-two capacity and two monotonically increasing indices. Only the producer writes the tail, only the consumer writes the head. The producer writes a slot then stores the tail with release; the consumer loads the tail with acquire, reads the slot, then stores the head with release, which the producer reads with acquire before reusing slots.
How would you make it faster?
Put the head and tail on separate cache lines to avoid false sharing, cache the other side's index locally and refresh it only when the queue looks full or empty, batch publication of indices, and construct large items in place rather than copying them.
What should a producer do when the queue is full?
It depends on the data. For orders, fail and let the caller handle it. For market data, which cannot be paused, drop, count and resynchronise, or conflate so that only the latest state per instrument is kept. Blocking a feed thread just moves the loss to the network card.