Skip to content
Work Practice Products Coding interviews Machine learning coding Quant developer HFT course Blog Method Why me About Enquire Book a call

Quant developer HFT course · Module 4: Concurrency

Threads, shared state and data races

Lesson · 11 min

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.

  1. Step 1 / 5
    thread Athread Bfills_load 41

    Thread A loads fills_ = 41 into a register.

  2. Step 2 / 5
    thread Athread Bfills_load 41load 41

    Thread B loads fills_ before A has stored: it also reads 41.

  3. Step 3 / 5
    thread Athread Bfills_load 41add 1load 41add 141

    Each adds one in its own register: both now hold 42.

  4. Step 4 / 5
    thread Athread Bfills_load 41add 1store 42load 41add 141

    A stores 42.

  5. Step 5 / 5
    thread Athread Bfills_load 41add 1store 42load 41add 1store 424142 (should be 43)

    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 after join() 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?

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?

Question 3 of 3

Which of these needs no synchronisation?

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.