Quant developer HFT course · Module 4: Concurrency
Threads, shared state and data races
The question
Two threads increment the same int: one counts fills from the gateway, the other counts them from the drop copy. The total is sometimes short. Why, and is "sometimes short" the worst that can happen?
Why the count is short
++fills_ looks like one operation. On the machine it is three: load the value into a register, add one, store it back. Two threads can interleave those steps:
Two threads increment the same counter. Each reads, adds and writes back; the reads overlap, so one increment is lost. With a plain int this is also undefined behaviour, so the compiler may do worse.
-
Step 1 / 5
Thread A loads fills_ = 41 into a register.
-
Step 2 / 5
Thread B loads fills_ before A has stored: it also reads 41.
-
Step 3 / 5
Each adds one in its own register: both now hold 42.
-
Step 4 / 5
A stores 42.
-
Step 5 / 5
B stores 42 over it. Two fills happened; the counter moved by one.
Both threads load 41, both add one, both store 42. One fill has disappeared. The more often the threads touch the counter at the same time, the more increments are lost, which is why the count is only sometimes short.
Why it is worse than a short count
In C++, two threads accessing the same memory location, at least one of them writing, without synchronisation, is a data race, and a data race is undefined behaviour. Not "the result may be wrong": the whole program has no defined meaning, and the compiler is entitled to assume it never happens.
That assumption has visible effects. The compiler may keep a variable in a register across a loop because, in a race-free program, no other thread can change it:
bool stop_ = false; // plain bool, shared between threads
void worker() {
while (!stop_) { poll_feed(); } // the compiler may read stop_ once and loop for ever
}
void shutdown() { stop_ = true; } // on another thread
If poll_feed is inlined and does not touch stop_, the optimiser can hoist the load out of the loop. The worker never sees the store. Code like this often works at -O0 and hangs at -O2.
The CPU reorders memory operations as well. Stores sit in a store buffer before they reach the cache, and on weakly ordered processors such as ARM, loads and stores to different addresses can become visible to other cores in a different order from the program's. Even on x86, a later load can be satisfied before an earlier store to a different address becomes visible.
So two layers, the compiler and the hardware, can both rearrange what one thread does as seen by another. The C++ memory model is the contract that tells you which arrangements are allowed, and synchronisation is how you rule the others out.
What counts as synchronisation
Shared mutable data is safe only if every access is ordered by one of:
- a mutex: everything done while holding it is visible to the next thread that takes it (lesson 2);
- an atomic variable, with the right memory ordering (lesson 4);
- thread start and join: everything before
std::thread's constructor is visible in the new thread, and everything the thread did is visible afterjoin()returns.
Data that is never written after it is shared needs no synchronisation: read-only configuration loaded before the threads start is fine. So is data owned by one thread and never touched by another, which is how most trading systems are built: each component owns its state, and threads exchange messages through queues instead of sharing objects.
The fix for the counter
std::atomic<std::uint64_t> fills_{0};
fills_.fetch_add(1, std::memory_order_relaxed); // one indivisible read-modify-write
fetch_add is a single atomic operation (lock xadd on x86). relaxed ordering is enough for a pure counter, because nothing else is published through it (lesson 4). Better still for a hot path, give each thread its own counter and sum them when someone reads the total, so the two threads never write the same cache line (Module 5).
Finding races
Races are timing-dependent and hide from ordinary tests. The tool that finds them is ThreadSanitizer: build with -fsanitize=thread, run the tests, and it reports each pair of conflicting accesses with both stack traces. It slows the program by several times and uses much more memory, so it runs in CI rather than production. Running the test suite under TSan as a matter of course is the answer interviewers want to hear.
Check yourself
Question 1 of 3
Why does ++counter lose increments when two threads run it on a shared int?
The read-modify-write is not indivisible. Interleaved loads read the same value, and the second store overwrites the first.
Question 2 of 3
A worker loops on while (!stop) {...} where stop is a plain bool set by another thread. What can happen at -O2?
Without synchronisation the compiler may assume no other thread writes stop, and hoist the load out of the loop. std::atomic<bool> fixes it.
Question 3 of 3
Which of these needs no synchronisation?
Thread creation synchronises with the parent's earlier writes, and data that is never written afterwards cannot race.
In the interview
Say your own answer out loud first, then open the one we would give.
What is a data race?
Two threads accessing the same memory location at the same time, at least one writing, without synchronisation. In C++ it is undefined behaviour, so beyond lost updates the compiler may optimise on the assumption it never happens, for example by caching a flag in a register.
How do you avoid data races?
Prefer designs where each piece of state is owned by one thread and threads communicate through queues. Where state must be shared, protect it with a mutex or make it atomic with the right memory ordering. Data that is immutable after the threads start needs nothing.
How would you find a race in a large codebase?
Run the tests under ThreadSanitizer, which reports each conflicting pair of accesses with stack traces. I would make it part of CI, because races depend on timing and ordinary test runs rarely expose them.
Is volatile enough to make a flag thread-safe?
No. volatile stops the compiler from removing or merging accesses, but it gives no atomicity and no ordering with other memory, so it is still a data race. std::atomic is the tool.