Quant developer HFT course · Module 4: Concurrency
Condition variables and producer–consumer queues
The question
Write a thread-safe queue where a consumer thread waits until there is something to process. Then tell me why our market-data path does not use it.
The correct blocking queue
template <class T>
class BlockingQueue {
std::mutex m_;
std::condition_variable cv_;
std::deque<T> q_;
public:
void push(T v) {
{
std::lock_guard g(m_);
q_.push_back(std::move(v));
}
cv_.notify_one(); // after unlocking: the woken thread can take the lock
}
T pop() {
std::unique_lock lk(m_);
cv_.wait(lk, [&] { return !q_.empty(); }); // re-checks the condition on every wake-up
T v = std::move(q_.front());
q_.pop_front();
return v;
}
};
Every line has a reason, and interviewers probe each one.
The mutex. The queue is shared state; every access is under the lock.
wait with a predicate. wait(lk, pred) is a loop: while the predicate is false, atomically unlock the mutex and sleep, then on waking relock and test again. The predicate is essential for two reasons:
- Spurious wake-ups. A waiting thread may return from
waitwithout any notification. The standard allows it and some implementations do it. - Stolen items. With several consumers, another thread may take the item between the notification and this thread reacquiring the lock.
Atomic unlock-and-sleep. wait releases the mutex and goes to sleep as one step from the point of view of other threads. That rules out the lost wake-up, where a notification arrives between checking the queue and starting to wait, and is never seen.
Notify after unlocking. Notifying while still holding the lock is correct, but the woken thread may then immediately block on the mutex. Notifying after the unlock avoids that extra hand-off. (Some implementations optimise the in-lock case; either is correct.)
A lost wake-up, written out
The bug the predicate and the mutex together prevent:
// WRONG: checks the condition without the lock, then waits
if (q_.empty()) { // consumer sees empty
// producer pushes and notifies here: nobody is waiting yet
std::unique_lock lk(m_);
cv_.wait(lk); // consumer sleeps, notification already gone
}
The rule: the condition must be checked and the wait entered under the same mutex that the producer holds while changing the condition.
Bounded queues and backpressure
An unbounded queue hides a slow consumer until memory runs out. A bounded queue makes the producer wait on a second condition variable when the queue is full, or return a failure so the producer can decide what to drop. For market data, where the producer is a network feed that cannot be paused, the choice is what to drop or conflate, which Module 7 covers.
Why the hot path does not use it
Waking a sleeping consumer goes through the kernel and the scheduler, typically several microseconds and sometimes far more. Latency-critical consumers spin on the queue instead of sleeping.
-
Step 1 / 4
The producer pushes an item while the consumer sleeps.
-
Step 2 / 4
It notifies the condition variable.
-
Step 3 / 4
The kernel wakes the consumer and the scheduler has to run it: microseconds, sometimes far more.
-
Step 4 / 4
Only then does the consumer reacquire the mutex and pop. A spinning consumer would have seen the item almost at once.
A consumer that sleeps on a condition variable must be woken by the kernel. The producer's notify becomes a futex system call; the consumer becomes runnable and waits for the scheduler to put it on a core; it then reacquires the mutex. Several microseconds is typical on an idle machine, with a long tail. The queue also allocates (the deque grows) and takes a lock on every push and pop.
A low-latency consumer instead runs on its own core and polls a lock-free queue in a loop. It never sleeps, so there is nothing to wake: a new item is seen within tens of nanoseconds, the time it takes the cache line to travel between cores. That costs a whole core per consumer, which trading firms happily pay. The next two lessons build that queue.
Blocking queues remain right for everything that is not latency-critical: logging, persistence, reporting, administration. Threads that would otherwise spin all day on idle work should sleep.
Check yourself
Question 1 of 3
Why must cv.wait be given a predicate, or be called in a loop that re-checks the condition?
Spurious wake-ups are allowed, and with several consumers another one may consume the item first. Re-checking under the lock handles both.
Question 2 of 3
What prevents a notification from being lost between checking the queue and starting to wait?
wait releases the mutex and sleeps as one step. The producer cannot change the condition and notify in between, because it needs the mutex.
Question 3 of 3
Why do latency-critical consumers spin on a queue instead of waiting on a condition variable?
Polling a lock-free queue on a dedicated core sees new data in the time it takes a cache line to move between cores. The price is a core that is always busy.
In the interview
Say your own answer out loud first, then open the one we would give.
What is a spurious wake-up?
A return from a condition variable wait without a matching notification. The standard permits it. Combined with the possibility that another consumer took the item, it means the condition must always be re-checked after waking, which wait with a predicate does.
What is a lost wake-up and how is it prevented?
A notification sent after the consumer checked the condition but before it started waiting, so it sleeps through it. It is prevented by checking the condition and entering wait under the same mutex the producer holds while changing the condition; wait releases the mutex and sleeps atomically.
Why not use condition variables on the market data path?
Every hand-off would go through a futex system call and the scheduler to wake the consumer, which adds microseconds and jitter, plus a lock and possibly an allocation per item. Hot consumers run on dedicated cores and poll lock-free queues instead.