Quant developer HFT course · Module 4: Concurrency
Mutexes, lock scope and contention
The question
The strategy thread and the gateway thread share a book protected by one std::mutex. Latency is fine on quiet days and terrible on busy ones. What is going on, and what would you change?
What a mutex costs
An uncontended std::mutex::lock() on Linux is an atomic compare-and-swap on a word in user space: the lock is free, the thread takes it, no system call. Unlock is another atomic operation. Together, tens of nanoseconds, most of it the cost of the atomic instructions and the cache line they touch.
Contention changes everything. When a thread finds the lock held, glibc's mutex spins briefly and then calls futex to sleep in the kernel. The unlocking thread must call into the kernel to wake it, and the woken thread waits for the scheduler to run it again. That is microseconds at best, and unbounded when the machine is busy. Busy days mean more contention, which matches the symptom.
Lock scope
The usual reason for contention is not the number of threads but how long each one holds the lock.
Lock scope. The log write inside the critical section makes every other thread that needs the lock wait for it. Copy what you need under the lock and do the slow part after.
-
Step 1 / 4
The strategy takes the lock.
-
Step 2 / 4
It updates the book: the only part that needs the lock.
-
Step 3 / 4
Then it formats and writes a log line while still holding it. The gateway has been waiting since it asked.
-
Step 4 / 4
Only after the unlock can the gateway send its order. The log line was paid for with someone else's latency.
void Strategy::on_fill(const Fill& f) {
std::lock_guard g(book_mutex_);
book_.apply(f);
log_ << "fill " << f.id << " px " << f.price << '\n'; // formatting and I/O under the lock
risk_.update(book_); // a long computation under the lock
}
Everything inside the braces holds the gateway off the book: formatting, a write that may block, a risk computation that does not need exclusive access to the book at all. The fix is to keep only the shared-state update inside the lock:
void Strategy::on_fill(const Fill& f) {
BookSummary snap;
{
std::lock_guard g(book_mutex_);
book_.apply(f);
snap = book_.summary(); // copy what the slow part needs
}
risk_.update(snap); // outside the lock
log_ring_.push(FillRec{f}); // to a logging thread, no formatting here
}
The questions for any critical section: what in here actually touches the shared state? Can the slow part work on a copy? Does anything in here allocate, log, or make a system call?
Choosing the lock type
std::lock_guard: locks for the lifetime of a scope. The default.std::unique_lock: movable, can be unlocked early and relocked, needed for condition variables (lesson 3).std::scoped_lock(C++17): locks several mutexes at once without deadlock.std::shared_mutex: many readers or one writer. Useful when reads dominate and are long; the bookkeeping makes it slower than a plain mutex for short critical sections.
Deadlock
Two threads, two mutexes, opposite order:
Thread 1 takes the book lock then wants the risk lock; thread 2 takes the risk lock then wants the book lock. Each waits for ever. The standard cures:
- A global lock order. Every thread that needs both takes the book lock first. Document it, and in debug builds assert it.
std::scoped_lock(a, b), which takes several locks using a deadlock-avoidance algorithm.- Never call unknown code while holding a lock: a callback could take another lock in the wrong order.
- Design out the need: if one thread owns the book and others send it messages, there is no lock to order.
Spinlocks
A spinlock never sleeps: a thread that finds it held loops until it is free. That avoids the futex wake-up cost, which makes it attractive when critical sections are a few dozen nanoseconds and each thread has its own core.
class Spinlock {
std::atomic<bool> held_{false};
public:
void lock() {
for (;;) {
if (!held_.exchange(true, std::memory_order_acquire)) return;
while (held_.load(std::memory_order_relaxed)) __builtin_ia32_pause(); // spin on a read
}
}
void unlock() { held_.store(false, std::memory_order_release); }
};
The inner loop spins on a plain load so the cache line stays shared until it changes, and pause tells the core it is spinning. Spinlocks are a disaster if the holder can be descheduled, because waiters burn their whole time slice; they belong on isolated cores where threads are never preempted (Module 6).
The real fix
In trading systems the best answer to "how do you reduce lock contention" is often to remove the shared state. The book belongs to one thread. Other threads that need its contents get snapshots or messages through a lock-free queue. The rest of this module builds that queue.
Check yourself
Question 1 of 3
What does an uncontended std::mutex lock and unlock cost on Linux?
The futex design keeps the uncontended path in user space. The kernel is entered only when a thread must sleep or be woken.
Question 2 of 3
Which change most reduces contention in a critical section that updates a book and writes a log line?
The cost is the time the lock is held. Moving the slow work outside the critical section shortens it; changing the lock type does not.
Question 3 of 3
Thread 1 locks A then B; thread 2 locks B then A. Which fix prevents deadlock?
Deadlock needs a cycle in the wait-for graph. A consistent order or an atomic multi-lock prevents the cycle.
In the interview
Say your own answer out loud first, then open the one we would give.
Why does lock contention hurt latency so much?
Uncontended locking stays in user space and costs a few atomic operations. Under contention, waiting threads sleep in the kernel and must be woken through a system call and the scheduler, which is microseconds and unpredictable. Long critical sections turn occasional overlap into constant contention.
How do you reduce contention?
Shorten critical sections to the shared-state update itself, moving logging, I/O and computation on copies outside. Split one lock into finer-grained locks where data is independent. Best, restructure so state has one owning thread and others communicate with it through queues.
How do you prevent deadlocks?
A global lock order that every thread follows, taking multiple locks with std::scoped_lock, and never calling unknown code while holding a lock. Or avoid holding more than one lock at all by design.
When would you use a spinlock?
For very short critical sections between threads pinned to their own cores, where sleeping and waking would cost more than spinning. Never where the holder can be preempted, because spinning waiters then waste whole time slices.