Quant developer HFT course · Module 8: Measuring latency
Debugging lock contention
The question
A service's throughput stops growing beyond four threads and CPU use is low. You suspect a lock. How do you confirm which lock, and what do you do about it?
The signature
Contention looks like waiting: throughput that stops scaling with threads, CPU utilisation that stays low while latency rises, many context switches, and time spent in futex system calls. A CPU profile shows little, because waiting threads are not running. Sampling profiles see on-CPU time; contention is off-CPU time.
Tools
- Off-CPU profiling. Record where threads block and for how long, with their stacks. BCC's
offcputime,perf sched record, orbpftracescripts on scheduler events give an off-CPU flame graph; the widest stacks blocked insidepthread_mutex_lockname the lock's callers. - futex tracing.
perf trace -e futexorstrace -f -e futexshows the waits and wakes, with the address of each futex. Repeated waits on one address identify one mutex. perf lockon kernels and builds that support it reports contended locks with wait times.- Instrumented mutexes. Wrapping the mutex to record the time between attempting and acquiring, and how long it was held, with the call site, gives the most direct answer and can stay in production at low cost:
class TimedMutex {
std::mutex m_;
public:
void lock(const char* site) {
auto t0 = tsc();
if (!m_.try_lock()) { m_.lock(); record_wait(site, tsc() - t0); } // only contended locks pay
held_since_ = tsc();
}
void unlock(const char* site) { record_hold(site, tsc() - held_since_); m_.unlock(); }
private:
std::uint64_t held_since_ = 0;
};
- Valgrind's DRD and Helgrind, and ThreadSanitizer, find lock-order problems and races, not contention, but are worth running for the deadlock side of lock bugs.
Reading the results
Contention is a product of two numbers: how often threads want the lock, and how long each holds it. The wait histogram tells you contention exists; the hold-time histogram per call site tells you who causes it. A lock held for 50 µs by one call site while others want it for 200 ns is a hold-time problem at that site. A lock held briefly but wanted by every thread for every request is a frequency problem: a single global serialisation point.
Fixes, from least to most work
- Shorten the critical section: move logging, allocation, I/O and computation on copies outside it (Module 4).
- Split the lock: separate locks for independent data, or striped locks (a lock per hash bucket or per instrument).
- Read-mostly data: a reader-writer lock, or better, publish immutable snapshots that readers access with no lock at all (an atomic pointer to the current version, or RCU).
- Per-thread state: per-thread counters, caches and buffers, merged occasionally.
- Ownership and messages: give the data a single owning thread and send it requests through queues.
Then measure again: wait and hold histograms per site, throughput against thread count, and off-CPU time.
Check yourself
Question 1 of 3
Why does a CPU sampling profile often fail to reveal lock contention?
Contention is time spent off the CPU. Off-CPU profiling or futex tracing captures it.
Question 2 of 3
An instrumented mutex shows that one call site holds the lock for 40 µs while others hold it for under 1 µs. What is the first fix?
Contention comes from hold time multiplied by demand. The long holder is the cause; spinning would only waste CPU while waiting for it.
Question 3 of 3
Read-heavy shared data is protected by a mutex and readers contend. Which change removes the readers' lock entirely?
Readers load the current pointer and read an object that never changes; the writer builds a new version and swaps it in. Reclaiming old versions needs care, for example with shared_ptr or RCU.
In the interview
Say your own answer out loud first, then open the one we would give.
How do you find lock contention?
Look for its signature, poor scaling with low CPU use and many context switches, then measure off-CPU time: off-CPU flame graphs, futex tracing to identify the contended address, perf lock where available, or an instrumented mutex recording wait and hold times per call site.
How do you fix lock contention?
In increasing effort: shorten critical sections, split or stripe the lock, use snapshots or RCU for read-mostly data, make state per-thread, or give the data a single owning thread fed by queues. Then verify with the same measurements.
Is a spinlock a fix for contention?
Rarely. It removes the sleep and wake-up cost, which helps very short critical sections between pinned threads, but if the problem is a long hold time, spinning just burns CPU while waiting. The fix is to hold the lock less.