Skip to content
Work Practice Products Coding interviews Machine learning coding Quant developer HFT course Blog Method Why me About Enquire Book a call

Quant developer HFT course · Module 4: Concurrency

The single-producer single-consumer ring buffer

Lesson · 13 min

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.

  1. Step 1 / 6
    ········headtail

    Empty: head and tail are both 0. head == tail means there is nothing to read.

  2. Step 2 / 6
    q0q1q2·····headtail

    The producer writes q0, q1, q2 into slots 0 to 2, then publishes tail = 3 with a release store.

  3. Step 3 / 6
    ··q2·····headtail

    The consumer reads slots 0 and 1, then publishes head = 2. Those two slots may now be reused.

  4. Step 4 / 6
    ··q2q3q4q5q6q7headtail

    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.

  5. Step 5 / 6
    q8q9q2q3q4q5q6q7headtail

    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.

  6. Step 6 / 6
    q8q9···q5q6q7headtail

    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 == tail and full when tail - 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 of tail_ 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 of head_ 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?

Question 2 of 3

Why are head_ and tail_ placed on separate cache lines?

Question 3 of 3

What does caching the consumer's head on the producer side achieve?

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.