Quantitative Finance · Book 18 · Careers

The Interview Book

The Interview Book · Careers

25Concurrency, Operating Systems and Networks

Two threads each add one to a shared counter a million times; the program prints a number well below two million. The candidate is asked for the smallest value it could print, which is not what anyone expects, and then for three ways to make it print two million, ranked by what each costs on a hot path. Systems questions at trading firms follow the path of a message through a machine: threads and the memory they share, the kernel and its system calls, virtual memory and caches, and the network stack that delivers the packet. The engineering is One Quant Book 13’s (chapters 3, 11 to 13) and Book 14’s; this chapter is the interview.

25.1 Atomics, locks and memory ordering

Two threads that access the same memory, at least one writing, without synchronisation, have a data race; in C++ and Rust that is undefined behaviour (One Quant Book 13, chapter 9), and ThreadSanitizer reports it. The classic lost update comes from ++counter being a load, an add and a store: two threads can load the same value and both store its successor. Under the simplest model of an interleaving (each thread’s steps in order, one global order of memory operations: sequential consistency) the outcomes can be enumerated exhaustively, which is the interview’s method for small programs.

Proposition 25.1 (How low can the counter go?)

If two threads each perform n≥2n \ge 2 increments by a separate load and store, the final value under sequential consistency is at least 2 and at most 2n2n, and 2 is reached. For n=1n = 1 it is 1 or 2.

Proof. Every store writes a loaded value plus one, so once a thread has stored, the counter is at least 1 forever. The final value is the last store, which is some thread’s last increment; for n≥2n \ge 2 that increment’s load comes after the same thread’s first store, so it reads at least 1 and stores at least 2. To reach 2: thread A loads 0; thread B runs n−1n - 1 increments; A stores 1; B loads 1; A runs its remaining n−1n - 1 increments; B stores 2. The chapter’s code enumerates every interleaving for n≤4n \le 4 and finds every value from 2 to 2n2n. ∎

Fixes, from cheapest to dearest on a hot path: give each thread its own counter and sum at the end (no sharing at all); an atomic fetch_add (a locked instruction on one cache line that bounces between cores under contention); a mutex around the increment (a system call when contended). Memory ordering (One Quant Book 13, chapter 11) decides what else becomes visible with an atomic operation: relaxed guarantees only atomicity, release/acquire make writes before a release visible after the matching acquire (the message-passing pattern), and seq_cst adds a single total order, needed for patterns such as store buffering, where each thread writes one flag and reads the other’s.

template <typename T, std::size_t N>
    requires(N > 1 && (N & (N - 1)) == 0)
class SpscQueue {
public:
    bool push(const T& v) {  // producer thread only
        const std::size_t t = tail_.load(std::memory_order_relaxed);
        if (t - head_.load(std::memory_order_acquire) == N) return false;  // full
        buf_[t & (N - 1)] = v;
        tail_.store(t + 1, std::memory_order_release);  // publishes the element
        return true;
    }
    std::optional<T> pop() {  // consumer thread only
        const std::size_t h = head_.load(std::memory_order_relaxed);
        if (h == tail_.load(std::memory_order_acquire)) return std::nullopt;  // empty
        T v = buf_[h & (N - 1)];
        head_.store(h + 1, std::memory_order_release);  // frees the slot
        return v;
    }

private:
    std::array<T, N> buf_{};
    alignas(64) std::atomic<std::size_t> head_{0};  // written by the consumer
    alignas(64) std::atomic<std::size_t> tail_{0};  // written by the producer, on its own cache line
};
Listing 25.1. A single-producer single-consumer queue: the release store of the index publishes the element; the two indices live on separate cache lines. code/interviews/25-concurrency-operating-systems-and-networks/cpp/iv_spsc.hpp

25.2 Processes, threads, scheduling and system calls

A system call crosses from user to kernel mode and back; a context switch saves one thread’s registers and restores another’s, and pollutes caches and translation tables. Both cost far more than a function call, which is why latency-critical threads are pinned to isolated cores, poll instead of sleeping (busy polling), and avoid the kernel on the data path (kernel bypass, One Quant Book 13, chapter 13). A mutex that is uncontended is a couple of atomic instructions; contended, it sleeps in the kernel; a spinlock never sleeps and suits very short critical sections on dedicated cores only.

25.3 Memory: virtual memory, page faults, caches and false sharing

Virtual addresses are translated through a radix tree of page tables: with 48-bit addresses, 4 KiB pages and 8-byte entries, each level resolves 9 bits and four levels plus a 12-bit offset cover the address. The translation lookaside buffer caches translations; its reach is its entry count times the page size, so huge pages (2 MiB) multiply it by 512. The first touch of a page faults (One Quant Book 13, chapter 6), which is why trading processes pre-fault their memory at start-up. Caches move 64-byte lines; two threads writing different variables in the same line make it bounce between cores (false sharing), which padding each hot variable to its own line removes.

25.4 Networks: what happens when a packet arrives

A received packet’s two routes to the application. Through the kernel: the network card writes the frame into memory by DMA, an interrupt starts the driver’s polling, the stack processes the headers and queues the data on the socket, and a system call copies it out. With kernel bypass the card’s ring is mapped into the application, which polls it: no interrupt, no system call, no copy.
Figure 25.1. A received packet’s two routes to the application. Through the kernel: the network card writes the frame into memory by DMA, an interrupt starts the driver’s polling, the stack processes the headers and queues the data on the socket, and a system call copies it out. With kernel bypass the card’s ring is mapped into the application, which polls it: no interrupt, no system call, no copy.

TCP gives a reliable ordered byte stream with congestion control and retransmission; UDP gives datagrams that may be lost, duplicated or reordered, with no head-of-line blocking. Market data is multicast over UDP (One Quant Book 13, chapter 16), because one sender reaches many receivers and a lost packet should not delay the next; receivers detect gaps with sequence numbers and recover from a second line or a retransmission service. Order entry uses TCP, because every message must arrive in order and a lost order must not go unnoticed.

25.5 Worked answers

Example 25.2 (Little’s law and a ring buffer)

“A decoding stage receives 200 000 messages a second and each spends 15 microseconds in it on average. How many are inside the stage at once? During a burst the feed delivers a million messages a second for a while and the stage still drains 200 000; how long can the burst last before a ring buffer of 1 024 slots overflows?” Little’s law: the average number in a stable system is the arrival rate times the time in the system, 200 000×15×10−6=3200\,000 \times 15 \times 10^{-6} = 3 messages. In the burst the queue grows at 106−2×105=800 00010^6 - 2 \times 10^5 = 800\,000 messages a second, so 1 024 slots last 1 024/800 000=1.281\,024/800\,000 = 1.28 milliseconds. Then the design questions the numbers raise: feeds burst for longer than that at the open, so either the buffer grows (at 64 bytes a slot, a million slots is 64 megabytes, which no longer fits in cache), or the stage gets faster, or it sheds work by conflating updates, and the answer should say which the business can accept.

25.6 Question bank

#include <cstdio>
#include <thread>

int counter = 0;  // shared, unsynchronised: every increment is a data race

void work() {
    for (int i = 0; i < 1000000; ++i) ++counter;
}

int main() {
    std::thread a(work), b(work);
    a.join();
    b.join();
    std::printf("%d\n", counter);
}
Listing 25.2. Two threads, one counter. code/interviews/25-concurrency-operating-systems-and-networks/cpp/snippets/race_counter.cpp

Interview question 25.1 ★ developer • market maker

Why does Listing 25.2 usually print less than two million? What does the C++ standard say about the program?

Solution

Solution of Interview question 25.1.

Each ++counter is a load, an add and a store; when the threads interleave between the load and the store, one increment overwrites the other and is lost. The standard says more: unsynchronised concurrent access with a write is a data race and the behaviour is undefined, so no output can be predicted, and ThreadSanitizer reports “data race” on this program (the chapter’s test asserts the diagnosis, never the number printed). Fix with an atomic, a mutex, or per-thread counters.

What the interviewer is looking for: the read-modify-write mechanism and the formal status of a data race.

Interview question 25.2 ★ developer • proprietary firm

When would you use a spinlock rather than a mutex?

Solution

Solution of Interview question 25.2.

When the critical section is a few instructions, the lock is rarely contended, and the waiting thread runs on its own core with nothing better to do, as on isolated cores of a trading engine: spinning avoids a sleep and a wake-up through the kernel. Never when the holder can be descheduled (a spinner then burns its whole time slice) or when many threads contend.

What the interviewer is looking for: the trade-off between spinning and sleeping, tied to core isolation.

Interview question 25.3 ★ developer • market maker

What is a context switch, what does it cost beyond the switch itself, and how do trading systems avoid them on the hot path?

Solution

Solution of Interview question 25.3.

The kernel saves one thread’s registers and loads another’s. Beyond that, the incoming thread finds caches, branch predictors and translation buffers holding the other thread’s state, so it runs slowly for a while. Trading systems pin hot threads to isolated cores, keep them busy polling rather than blocking, and move non-critical work to other cores (One Quant Book 13, chapter 13).

What the interviewer is looking for: direct and indirect costs, and pinning and isolation as the remedy.

Interview question 25.4 ★ developer, trader • any

Why do venues usually publish market data by UDP multicast but take orders on TCP sessions?

Solution

Solution of Interview question 25.4.

Market data goes to many receivers at once, and a late update is worthless: multicast UDP sends once to all subscribers and never blocks on a lost packet; receivers detect gaps by sequence number and recover separately. Orders must arrive, once and in order, and a loss must be known: TCP gives a reliable ordered stream and a session whose failure is visible.

What the interviewer is looking for: fan-out and staleness against reliability and order.

Interview question 25.5 ★★ developer • proprietary firm

In the model where each increment is a separate load and store, what is the smallest value the program of Listing 25.2 could print? Describe the interleaving.

Solution

Solution of Interview question 25.5.

Two (for a million increments each). Thread A loads 0 and stalls; thread B completes all but its last increment; A stores 1, overwriting B’s work; B loads 1 for its last increment and stalls; A completes its remaining increments; B stores 2. An exhaustive enumeration of all interleavings for small counts (Proposition 25.1) confirms that 2 is the minimum. In C++ the program is a data race, so even this analysis describes the machine model, not a guarantee of the language.

What the interviewer is looking for: the adversarial interleaving and the caveat about undefined behaviour.

Interview question 25.6 ★★ developer • market maker

Thread 1 writes x = 1 then reads y; thread 2 writes y = 1 then reads x; both start at 0. Can both threads read 0? Answer under sequential consistency, on x86, and with C++ relaxed atomics, and say how to forbid it.

Solution

Solution of Interview question 25.6.

Under sequential consistency no: some write comes first in the single order, so the other thread’s later read sees it; the chapter’s explorer finds only (0,1), (1,0) and (1,1). On x86, yes: each store can sit in its core’s store buffer while the load after it reads memory. With C++ relaxed (or even acquire and release) atomics, yes. To forbid it, make all four operations seq_cst or put a seq_cst fence between each store and the following load (on x86, an mfence or a locked instruction).

What the interviewer is looking for: store buffering, the difference between SC and TSO, and the full fence.

Interview question 25.7 ★★ developer • proprietary firm

A producer writes a message then sets a flag; a consumer waits for the flag then reads the message. Which memory orderings make this correct, and which outcome do they forbid?

Solution

Solution of Interview question 25.7.

The flag store is a release and the flag load an acquire: every write before the release (the message) is then visible to a thread whose acquire reads the flag’s new value. It forbids “flag seen, stale message”, the outcome (1, 0) of the chapter’s message-passing test, which sequential consistency also forbids and relaxed ordering allows. This is the pattern of the SPSC queue’s indices.

What the interviewer is looking for: the release–acquire pairing and the outcome it excludes.

Interview question 25.8 ★★ developer • market maker

Two threads each update their own int64_t counter in an array of eight counters, and the program is slower than with one thread. Why, and what is the fix?

Solution

Solution of Interview question 25.8.

Eight 8-byte counters fill one 64-byte cache line, so each write invalidates the line in the other core’s cache and the line ping-pongs between cores (false sharing, One Quant Book 13, chapter 3). Give each counter its own line (alignas(64) or padding), or keep counters thread-local and combine them at the end.

What the interviewer is looking for: cache-line granularity of coherence and padding as the fix.

Interview question 25.9 ★★ developer • proprietary firm

With 48-bit virtual addresses, 4 KiB pages and 8-byte page-table entries, how many levels of page table are there? A TLB has 1 536 entries: how much memory does it cover using 4 KiB pages, and using 2 MiB huge pages?

Solution

Solution of Interview question 25.9.

A page holds 4096/8=5124096/8 = 512 entries, so each level resolves 9 bits; the offset takes 12 bits; (48−12)/9=4(48 - 12)/9 = 4 levels. TLB reach: 1536×4 KiB=6 MiB1536 \times 4\,\text{KiB} = 6\,\text{MiB} with small pages, 1536×2 MiB=3 GiB1536 \times 2\,\text{MiB} = 3\,\text{GiB} with huge pages, so a working set of a few gigabytes stays translated with huge pages and misses constantly without them.

What the interviewer is looking for: the radix arithmetic and the reach argument for huge pages.

Interview question 25.10 ★★★ developer • market maker

Write a lock-free single-producer single-consumer queue and justify every memory ordering in it.

Solution

Solution of Interview question 25.10.

Listing 25.1. The producer reads its own tail relaxed (only it writes it), reads head with acquire (so the consumer’s reads of a slot happen before the producer overwrites it), writes the element, then stores tail with release (publishing the element). The consumer mirrors it: acquire on tail, read, release on head. The indices sit on separate cache lines to avoid false sharing. The chapter’s stress test passes two million sequence numbers in order and runs clean under ThreadSanitizer.

What the interviewer is looking for: each ordering justified by what it publishes, and the cache-line layout.

Interview question 25.11 ★★★ developer • proprietary firm

What happens, step by step, between a UDP packet arriving at the network card and the application having its payload? Which steps does kernel bypass remove?

Solution

Solution of Interview question 25.11.

The card receives the frame, checks it and writes it by DMA into a ring of buffers in host memory; it raises an interrupt, the driver schedules polling (NAPI) and hands the buffer to the stack; IP and UDP headers are processed, the socket is looked up and the payload queued on its buffer; a thread blocked in recvmsg is woken, and the system call copies the payload to user memory (Figure 25.1). Kernel bypass maps the card’s ring into the process, which polls it: the interrupt, the stack traversal, the wake-up, the system call and the copy all disappear.

What the interviewer is looking for: the full path, and exactly which steps bypass removes.

Interview question 25.12 ★★★ developer, trader • crypto firm

A multicast feed carries sequence numbers. Write the receiver’s gap detector, allowing for late and duplicated packets, and say what the receiver does when a gap stays open.

Solution

Solution of Interview question 25.12.

def gaps(seqs):
    """Missing sequence-number ranges in a stream that should be consecutive; duplicates and late
    arrivals are ignored. Returns a list of (first_missing, last_missing)."""
    expected, out, seen_late = None, [], set()
    for s in seqs:
        if expected is None:
            expected = s + 1
            continue
        if s == expected:
            expected += 1
        elif s > expected:
            out.append((expected, s - 1))
            expected = s + 1
        else:
            seen_late.add(s)
    # late arrivals fill earlier gaps
    filled = []
    for a, b in out:
        cur = a
        for x in range(a, b + 1):
            if x in seen_late:
                if cur <= x - 1:
                    filled.append((cur, x - 1))
                cur = x + 1
        if cur <= b:
            filled.append((cur, b))
    return filled
A gap detector that tolerates duplicates and fills gaps with late arrivals. code/interviews/25-concurrency-operating-systems-and-networks/python/iv_conc.py

Track the next expected number; a higher one opens a gap, a lower one is a duplicate or a late arrival that closes part of a gap. When a gap stays open past a timeout, take the packet from the redundant line (arbitration), request a retransmission, or, for a book feed, mark the book invalid and rebuild from a snapshot (One Quant Book 10, chapter 26). The chapter’s test compares the detector with a direct count of missing numbers on 300 random streams.

What the interviewer is looking for: gap bookkeeping with late packets, and the recovery hierarchy.

Interview question 25.13 ★★★ developer • any

Thread A locks the order book then the risk state; thread B locks the risk state then the order book. What can happen, and give two ways to prevent it.

Solution

Solution of Interview question 25.13.

Deadlock: A holds the book and waits for the risk state while B holds the risk state and waits for the book. Prevent it by a global lock order (always the book first), or by acquiring both at once with a deadlock-avoiding primitive (std::scoped_lock on both mutexes); better still, design so that one thread owns each piece of state and others send it messages.

What the interviewer is looking for: the circular wait and the standard preventions.

Sources and further reading

  • M. Herlihy and N. Shavit, The Art of Multiprocessor Programming, revised reprint, 2012.
  • J. Postel, RFC 768, User Datagram Protocol, 1980; W. Eddy (ed.), RFC 9293, Transmission Control Protocol (TCP), 2022.
  • ISO/IEC 14882:2020, [intro.races] and [atomics.order]; One Quant Book 13, chapters 3, 6, 9, 11–13 and 16.