Quant developer HFT course · Module 4: Concurrency
Lock-free queues, ABA and seqlocks
The question
The SPSC queue works for one producer. Now three strategy threads all send orders to one gateway thread. What changes, and what can go wrong?
Progress guarantees
Three terms, often confused:
- Blocking. A thread that is suspended at the wrong moment, holding a lock, can stop every other thread.
- Lock-free. Some thread always makes progress: if threads keep running, the system as a whole does not stall, though an individual thread can be made to retry indefinitely.
- Wait-free. Every thread completes its operation in a bounded number of its own steps, whatever the others do.
The SPSC ring is wait-free: push and pop are a fixed sequence of instructions. A typical multi-producer queue is lock-free: a producer may lose a race and retry.
Lock-free is a statement about progress, not about speed. A well-designed mutex queue can outperform a lock-free one under contention, because the lock-free queue's atomics all hit the same cache line.
Several producers
With several producers, the tail is no longer owned by one thread; producers must claim slots without clashing. The common bounded design gives each slot a sequence number:
// multi-producer, single-consumer bounded ring (sketch)
struct Slot { std::atomic<std::uint64_t> seq; Order value; };
bool push(const Order& o) {
std::uint64_t pos = tail_.load(std::memory_order_relaxed);
for (;;) {
Slot& s = slots_[pos & (N - 1)];
auto seq = s.seq.load(std::memory_order_acquire);
if (seq == pos) { // slot free for this lap
if (tail_.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed)) {
s.value = o;
s.seq.store(pos + 1, std::memory_order_release); // publish to the consumer
return true;
} // lost the race: pos reloaded, retry
} else if (seq < pos) return false; // full
else pos = tail_.load(std::memory_order_relaxed); // another producer moved on
}
}
Producers compete only for the tail with a compare-and-swap; once a producer has claimed a position, it fills the slot and publishes it through the slot's own sequence number. The consumer waits on the sequence number of the slot it wants next. This is the design behind several widely used bounded queues.
Its weakness is the shared tail: every producer's CAS touches the same cache line, so throughput falls as producers are added. In trading systems a frequent alternative is one SPSC ring per producer, with the consumer polling each in turn. That keeps every queue wait-free and every line owned by two threads.
The ABA problem
Unbounded lock-free structures built from linked nodes hit a classic bug. Take a lock-free stack with pop written as a compare-and-swap on the top pointer:
Node* pop() {
Node* old = top_.load();
while (old && !top_.compare_exchange_weak(old, old->next)) {}
return old;
}
The ABA problem in a lock-free stack. Thread 1 compares the top with A and swaps in A's old next pointer, B, which thread 2 has already popped and freed. The compare succeeds because the value looks unchanged.
-
Step 1 / 5
Thread 1 reads top = A and A's next = B, then is preempted.
-
Step 2 / 5
Thread 2 pops A. The top is now B.
-
Step 3 / 5
Thread 2 pops B and frees it. The top is C.
-
Step 4 / 5
Thread 2 pushes A back, reusing the same node. The top is A again.
-
Step 5 / 5
Thread 1 resumes. The top still equals A, so its compare-and-swap succeeds and installs B, a freed node.
Thread 1 reads top = A and A's next = B, then is preempted. Thread 2 pops A, pops B (freeing it), and pushes A back, perhaps reusing the same node. Thread 1 resumes: the top is A again, so its CAS succeeds and sets top to B, a node that has been freed. The CAS checked that the value was the same, not that nothing had happened.
Fixes:
- Tagged pointers. Pair the pointer with a counter incremented on every change and CAS both together (a 16-byte CAS, or spare bits of the pointer). A recycled A has a different tag.
- Safe memory reclamation. Never free or reuse a node while another thread might still hold a pointer to it. Hazard pointers (each thread announces the pointers it is using), epoch-based reclamation and RCU all do this, and C++26 adds
std::hazard_pointerandstd::rcu. - Avoid the problem. Bounded rings over preallocated arrays use indices and sequence numbers, not freed nodes, which is one reason they dominate in latency-sensitive code.
Seqlocks: one writer, many readers, no blocking
A different and very common pattern: one thread maintains a small piece of state, say the top of the book for an instrument, and many threads read it whenever they like. Readers must never slow the writer down.
A seqlock pairs the data with a sequence counter. The writer increments the counter to an odd number, writes the data, then increments it to an even number. A reader reads the counter, copies the data, reads the counter again, and keeps the copy only if both reads were equal and even; otherwise it retries.
A seqlock. The writer makes the counter odd while it writes and even when done. A reader copies the data and accepts the copy only if the counter was even and unchanged; otherwise it tries again. Readers never block the writer.
-
Step 1 / 5
Reader 1 reads seq = 6, which is even, and starts copying.
-
Step 2 / 5
The writer sets seq = 7. Odd means a write is in progress.
-
Step 3 / 5
Reader 1 finishes its copy and reads seq again: 7, not 6, so the copy may be torn. It will retry.
-
Step 4 / 5
The writer finishes and sets seq = 8.
-
Step 5 / 5
Both readers now see 8 before and after their copy: consistent. The writer never waited for either of them.
struct TopOfBook { std::int64_t bid, ask; std::int32_t bid_qty, ask_qty; };
class SeqTop {
std::atomic<std::uint64_t> seq_{0};
TopOfBook data_; // see the note below on how it is copied
public:
void write(const TopOfBook& t) { // single writer
auto s = seq_.load(std::memory_order_relaxed);
seq_.store(s + 1, std::memory_order_relaxed); // odd: write in progress
std::atomic_thread_fence(std::memory_order_release);
data_ = t;
seq_.store(s + 2, std::memory_order_release); // even: done
}
TopOfBook read() const {
for (;;) {
auto s1 = seq_.load(std::memory_order_acquire);
if (s1 & 1) continue; // writer busy
TopOfBook copy = data_;
std::atomic_thread_fence(std::memory_order_acquire);
if (seq_.load(std::memory_order_relaxed) == s1) return copy;
}
}
};
The writer never waits for readers. Readers may retry, but a reader cannot block the writer, which is exactly the right trade for market data. One subtlety: the reader copies data_ while it may be being written, which is formally a data race on a non-atomic object even though the torn copy is then discarded. Production implementations copy the data through relaxed atomic word-sized loads, or use the compiler-specific guarantees their codebase relies on. Saying that out loud shows you know the memory model, not just the pattern.
Check yourself
Question 1 of 3
What is the difference between lock-free and wait-free?
A lock-free operation can retry indefinitely if it keeps losing races; a wait-free one cannot. The SPSC ring is wait-free; CAS-loop queues are usually lock-free.
Question 2 of 3
What does the ABA problem break?
CAS compares values, not histories. A pointer to a recycled node passes the comparison while the node's surroundings are different.
Question 3 of 3
In a seqlock, how does a reader know its copy may be torn?
The writer makes the counter odd during a write and changes it on completion. Equal even values before and after mean no write overlapped the copy.
In the interview
Say your own answer out loud first, then open the one we would give.
What is the difference between lock-free and wait-free?
Lock-free guarantees system-wide progress: some operation always completes, though a given thread may retry. Wait-free guarantees every operation completes in a bounded number of steps. The SPSC ring is wait-free; most multi-producer queues built on CAS loops are lock-free.
What is the ABA problem and how do you fix it?
A thread reads a value, is delayed, and then a compare-and-swap succeeds because the value is the same again, even though the structure changed, typically because a node was freed and reused. Fixes are tagged pointers with a version counter, safe reclamation such as hazard pointers or epochs so nodes are not reused while referenced, or index-based bounded structures that do not free nodes.
How would you let many threads read the latest top of book without slowing the writer?
A seqlock: a sequence counter the writer makes odd while writing and even when done. Readers copy the data and retry if the counter was odd or changed. The writer never blocks; readers occasionally retry. The data copy should be done with atomic word loads to stay within the memory model.
Several producers send to one consumer. What design would you use?
Often one SPSC ring per producer, with the consumer polling them in turn: each ring stays wait-free and each cache line is shared by only two threads. If the number of producers is large or dynamic, a bounded multi-producer ring with per-slot sequence numbers, accepting contention on the tail.