Sit through a Citadel Securities or Hudson River Trading onsite as a developer and, somewhere past the C++ warmup, someone asks you to write a single-producer, single-consumer queue that two threads share without a lock. No mutex. No std::mutex anywhere. Just a ring buffer, two atomic counters, and you talking through why it stays correct while both threads hammer it at full speed.
That prompt is the anchor of the quant developer loop, and it looks nothing like the puzzle-and-probability rounds a quant researcher gets. Developers at HRT, Jump, Tower, DRW, and Citadel Securities are hired to keep a trading system fast and correct at the nanosecond level, so the interview drills into the machine: the C++ memory model, atomics, cache behavior, and how a data structure actually behaves once it is running on real cores. The language screen that comes first, the questions about std::move and virtual dispatch and where a copy sneaks in, is table stakes. The signal is in what happens after you get the syntax right.
The queue they actually hand you
The setup is two threads and a fixed-size buffer. One thread pushes market-data messages in, the other pops them out and acts on them. You are not allowed a lock, because a lock means a system call under contention, and a system call means you have already blown the latency budget. The tool is a ring buffer with a power-of-two capacity and two indices, one owned by the producer and one by the consumer.
A minimal version a strong candidate can write and defend on the whiteboard:
template <class T, size_t N> // N must be a power of two
class SpscRing {
static_assert((N & (N - 1)) == 0, "capacity must be a power of two");
alignas(64) std::atomic<size_t> head_{0}; // advanced by the consumer
alignas(64) std::atomic<size_t> tail_{0}; // advanced by the producer
alignas(64) std::array<T, N> buf_;
public:
bool push(const T& v) {
const size_t t = tail_.load(std::memory_order_relaxed);
const size_t next = (t + 1) & (N - 1);
if (next == head_.load(std::memory_order_acquire))
return false; // full
buf_[t] = v;
tail_.store(next, std::memory_order_release);
return true;
}
bool pop(T& out) {
const size_t h = head_.load(std::memory_order_relaxed);
if (h == tail_.load(std::memory_order_acquire))
return false; // empty
out = buf_[h];
head_.store((h + 1) & (N - 1), std::memory_order_release);
return true;
}
};
Each thread loads the index it owns with a relaxed load, because nobody else writes it. It loads the other thread’s index with acquire, because it needs to see everything that thread published. And it advances its own index with a release store, which is what publishes the slot it just wrote or read. The power-of-two capacity turns the wrap-around modulo into a single bitwise mask. There is a deliberate gotcha worth naming out loud: with head == tail meaning empty, you sacrifice one slot, so an N-element buffer holds N-1 messages. Say that before the interviewer asks, and you have shown you actually traced the states.
The follow-ups come fast. What if T is not trivially copyable? How would you add a batched pop that drains sixteen messages at once? Can you cache the other thread’s index locally and refresh it only when you run out, so you skip an atomic load on the common path? Each of those is a real optimization that shows up in production queues, and each one is a chance to show that you cost out an instruction instead of only checking that it works.
Memory ordering is the real question hiding inside the queue
The moment you write memory_order_relaxed, the interviewer wakes up. Why relaxed there and acquire on the other load? Could you set every operation to seq_cst and go home? You could, and it would be correct, and on x86 it might even cost close to nothing. But on ARM, which is where Apple silicon and a lot of newer trading hardware lives, sequential consistency inserts barrier instructions you do not need, and “I reached for the strongest ordering because I wasn’t sure” is the answer that quietly loses the round.
The four orderings that come up, and where each one lands in the queue above:
| C++ memory order | What it guarantees | Where it appears in the SPSC queue |
|---|---|---|
| memory_order_relaxed | Atomicity of the operation only; no ordering constraint against other loads or stores in the thread | Loading the index your own thread owns and just updated |
| memory_order_acquire | No read or write later in this thread can move before the load; it sees everything published by the matching release | Reading the other thread’s index before you trust the buffer contents behind it |
| memory_order_release | No read or write earlier in this thread can move after the store; it publishes those writes to an acquiring reader | Advancing your own index after you finish writing or reading a slot |
| memory_order_seq_cst | Acquire and release semantics plus a single global total order over all seq_cst operations across threads | The correct-but-heavy default you are expected to justify replacing, not the target |
The pairing is the whole idea. A release store on the producer synchronizes with an acquire load on the consumer, and that pairing is what makes the buffer write visible before the reader touches it. Break the pair, use a relaxed store where you meant release, and the code still compiles, still passes a light test on x86 where the hardware is forgiving, and then corrupts data the first time it runs on a weakly ordered core under load. Interviewers who ship to specific hardware ask about exactly that x86-versus-ARM gap, because it is where “works on my laptop” becomes a production incident.
Two atomics on one cache line, and why that halves your throughput
Look again at the alignas(64) on head_ and tail_. That is doing real work. A cache line on mainstream CPUs is 64 bytes, and a modern coherence protocol tracks ownership at that granularity, not per variable. Put the producer’s counter and the consumer’s counter next to each other in memory and they land on the same line. Now every time the producer writes tail_, it invalidates the consumer core’s copy of the line, and every time the consumer writes head_, it invalidates the producer’s copy, even though the two threads never touch the same variable. That is false sharing, and it turns two independent counters into a cache-line ping-pong that can cut queue throughput by half or worse.
The fix is what the code already does: force each counter onto its own line so the cores stop fighting over shared ownership. Some candidates hardcode 64; the more careful answer names std::hardware_destructive_interference_size from C++17, which lets the platform tell you the real line size instead of you guessing. Either way, the interviewer is checking that you think in cache lines. An L1 hit is roughly a nanosecond, a few cycles; a trip to main memory is on the order of a hundred nanoseconds. When your budget for reacting to a market update is measured in low hundreds of nanoseconds, a stray cache miss is not a rounding error. It is the whole event.
This is the mental shift the developer loop is testing for. In a general software interview, “O(1) amortized” ends the performance conversation. Here it starts it. Two operations with identical big-O can differ by an order of magnitude depending on whether the data is already in L1, whether the branch predicts well, and whether the layout invites false sharing. A candidate who reasons about where the bytes physically live is the one who gets the callback.
The order book, branch prediction, and how the firms differ
The queue rarely stands alone. It feeds an order book, and building that book is its own round: add, cancel, and best-bid-offer, usually with a target of constant or logarithmic time per operation. The instructive follow-up is always about the hot path. Cancels dominate real order flow, and most cancels sit near the top of the book, so once you know the price range you can beat a balanced tree with a flat array of price levels indexed by price, trading memory for a branch-free lookup. The point the interviewer is fishing for is that you optimize for the operation mix you will actually see, not the one that is easiest to reason about in the abstract.
A representative slice of what gets asked once the language screen is behind you:
- “Here is a 40-byte struct. Make it fit in a cache line and tell me what you gave up to do it.”
- “This loop branches on a rare error condition. How does the branch predictor treat it, and how would you make the common path predictable?”
- “Two threads each increment a shared atomic counter a billion times. Why can that be slower than one thread incrementing it two billion times?”
- “Design an order book with add, cancel, and BBO, then tell me which operation dominates in production and re-optimize the layout for it.”
Which of these you get depends heavily on the shop. Jane Street runs its trading systems on OCaml, so its developer interview leans on systems reasoning and problem decomposition more than C++ trivia; you can get the same false-sharing question without writing a line of C++. HRT, Jump, Citadel Securities, Tower, and DRW test C++ directly and to real depth, memory model and all. Optiver and IMC sit closer to the trading desk and often go lighter on low-level C++ for developer roles. Reading the job title and the firm before you walk in tells you which muscle to warm up.
The people who pass are not the ones who memorized that release pairs with acquire. They are the ones who, when the interviewer points at a relaxed load and asks “why is this safe,” can name the exact write that store publishes, the exact read that consumes it, and what breaks the moment you weaken either one. That reasoning, out loud, over a twenty-line ring buffer, is most of the interview.
