← Back to roadmap
Phase 3Week 30~120 min read
0%·0/5

Threads, Cores & Busy Polling

Pinning, isolation, and when spinning beats sleeping.

Performance · Measure, Profile, Then Go Fast

Why this week

Up to now the operating system decided where your threads ran and what they did while waiting. Its decisions are excellent for throughput and fairness — and terrible for latency. A thread that blocks on a futex is woken through a syscall on another core, an inter-processor interrupt, a pass through the scheduler and possibly a wake-up from a deep idle state: microseconds to tens of microseconds, every time. A thread the scheduler migrates arrives on a core with cold caches. A thread that shares a physical core with a noisy neighbour runs at half speed for reasons no profile of your code will show.

Low-latency systems take those decisions back. Each latency-critical thread gets its own core, chosen deliberately: on the NIC's NUMA node, away from the core that handles interrupts and housekeeping, with its SMT sibling left idle. And it does not sleep: it busy-polls its input, burning a whole core to turn a 20 µs wakeup into a cache-line transfer of well under a microsecond. Threads that are not latency-critical use a hybrid — spin briefly, then yield, then park — which is only correct if you get the memory ordering of "I'm going to sleep" exactly right.

Day 1 — What waiting costs

Day 01

The path of a wakeup

Futexes, schedulers and idle states

Bookby Mara Bos · ch. 8
Rust Atomics and Locks

Operating System Primitives: how parking really works on Linux (futex wait/wake), what thread::park and unpark promise, and why a wait must always re-check its condition. The whole book is free to read online.

Docsby Erik Rigtorp
Low latency tuning guide

A compact, practical checklist from someone who has done this for trading systems: SMT, frequency scaling, isolcpus/nohz_full, interrupt affinity, transparent huge pages. Skim it now; you will come back to it on Day 3.

When a consumer parks and a producer later unparks it, the producer makes a futex wake syscall; the kernel marks the consumer runnable and, if it belongs on another CPU, sends that CPU an inter-processor interrupt; if that CPU was idle it may have to leave a deep C-state first; the scheduler then switches to the consumer, which returns from its own futex wait syscall into user space with partly cold caches. Each step is cheap by OS standards. Together they typically cost from a few microseconds to tens of microseconds, with a long tail — on the machine these drills were tested on, a park/unpark wakeup measured around 16 µs.

Compare a busy-polling consumer: it is already running, spinning on a cache line. The producer's store invalidates that line in the consumer's cache; the consumer's next load fetches it from the producer's core. That is a core-to-core cache-line transfer — tens of nanoseconds within a socket on current hardware, more across sockets.

Idle states deserve their own sentence: a core in a deep C-state can take tens of microseconds — over a hundred on some server parts — to wake. Check /sys/devices/system/cpu/cpu0/cpuidle/state*/latency on your machine. Low-latency hosts limit C-states (kernel parameters, or holding /dev/cpu_dma_latency open with a value of 0) precisely so that a core that does sleep wakes quickly.

Recall#wakeup-path

Why does waking a parked thread cost microseconds when a busy-polling thread sees the same message in well under one?

Day 2 — Spin, yield, park — and the lost wakeup

Day 02

A wait strategy that is cheap when busy and correct when idle

Drill 1: spin-then-park without losing a notification

Docs
LMAX Disruptor

Read the technical paper and the section on wait strategies: BusySpin, Yielding, Sleeping and Blocking. Each is a point on the latency vs CPU-cost curve, and LMAX picks per consumer.

Bookby Mara Bos · ch. 9
Rust Atomics and Locks

Building Our Own Locks: a futex-based mutex and condition variable, including the 'spin a little before sleeping' optimisation and the bookkeeping that avoids unnecessary wake syscalls.

The hybrid strategy escalates:

  1. Spin with std::hint::spin_loop() — the x86 PAUSE instruction, which tells the core this is a spin-wait (saving power and, on SMT cores, giving issue slots to the sibling). Its latency varies a lot by microarchitecture: on Intel cores since Skylake it takes on the order of a hundred cycles or more, versus roughly ten on earlier ones, so "spin N times" is not a portable unit of time.
  2. Yield with thread::yield_now() — a sched_yield syscall that lets another runnable thread on this CPU run, and returns immediately if there is none.
  3. Park — sleep in the kernel until unparked.

The notifier wants to skip the expensive unpark syscall when the waiter is still spinning. So the waiter announces "I'm about to sleep" with a flag, and the notifier only unparks if it sees the flag. Here is the trap:

  • Waiter: sleeping = true, then check ready; if not ready, park.
  • Notifier: ready = true, then check sleeping; if set, unpark.

Each side stores one variable and then loads the other. That is the store-buffering pattern, and with anything weaker than sequentially consistent ordering both loads may see the old values — even on x86, where each core's store buffer lets a later load overtake an earlier store. The waiter sees ready == false and parks; the notifier sees sleeping == false and does not unpark. The notification is lost, and the waiter sleeps until the next one — or forever.

Litmus · Store buffering (SB)
Initially x = y = 0
Thread 1
x.store(1, Relaxed);
let r1 = y.load(Relaxed);
Thread 2
y.store(1, Relaxed);
let r2 = x.load(Relaxed);
Can r1 == 0 && r2 == 0 happen?
⚠ Allowed by the Rust/C++20 modelObservable on x86-64

Each store can sit in its core’s store buffer while the following load reads memory. This is the one reordering x86-TSO itself performs (store→load).

The fix is SeqCst on all four accesses (or SeqCst fences between each store and the following load), which forbids the outcome where both loads miss. And thread::park itself is forgiving in the right way: an unpark that arrives before the park leaves a token, so the park returns immediately; parks may also return spuriously, so the waiter always loops and re-checks.

Drill

Spin-then-park backoff

Target: 7/7 cases passNot yet run
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use std::thread::{self, Thread};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Step {
    Spin,
    Yield,
    Park,
}

/// Escalating wait strategy: spin a little, yield a little, then park.
pub struct Backoff {
    spin_limit: u32,
    yield_limit: u32,
    step: u32,
}

impl Backoff {
    pub fn new(spin_limit: u32, yield_limit: u32) -> Self {
        Backoff { spin_limit, yield_limit, step: 0 }
    }

    /// The first `spin_limit` calls return Spin, the next `yield_limit`
    /// return Yield, and every call after that returns Park.
    /// (Beware overflow when spin_limit is u32::MAX.)
    pub fn next(&mut self) -> Step {
        todo!()
    }

    pub fn reset(&mut self) {
        todo!()
    }
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct WaitStats {
    pub spins: u64,
    pub yields: u64,
    pub parks: u64,
}

/// Auto-reset event for ONE waiting thread and any number of notifiers.
pub struct Event {
    ready: AtomicBool,
    sleeping: AtomicBool,
    waiter: Mutex<Option<Thread>>,
    unparks: AtomicU64,
}

impl Event {
    pub fn new() -> Self {
        Event {
            ready: AtomicBool::new(false),
            sleeping: AtomicBool::new(false),
            waiter: Mutex::new(None),
            unparks: AtomicU64::new(0),
        }
    }

    /// Block until notified, consuming the notification (ready -> false).
    /// 1. Register thread::current() in `waiter`.
    /// 2. Loop: if ready, consume it and return the stats. Otherwise act on
    ///    backoff.next(): spin (std::hint::spin_loop), yield, or park.
    /// 3. Before parking: set `sleeping`, RE-CHECK `ready`, and only park if
    ///    it is still false. Clear `sleeping` after waking. Count a park only
    ///    when you actually called thread::park(). Mind the orderings.
    pub fn wait(&self, backoff: &mut Backoff) -> WaitStats {
        todo!()
    }

    /// Publish readiness; unpark the waiter only if it announced `sleeping`,
    /// and count each unpark in `unparks`.
    pub fn notify(&self) {
        todo!()
    }

    /// How many times notify actually called unpark (a futex syscall each).
    pub fn unpark_calls(&self) -> u64 {
        self.unparks.load(Ordering::Relaxed)
    }
}
Rust
Recall#sb-lost-wakeup

The waiter stores sleeping=true then loads ready; the notifier stores ready=true then loads sleeping. Why is Release/Acquire not enough, and what goes wrong?

Day 3 — Pinning and isolation

Day 03

Give every hot thread a core of its own

Drill 2: turn a topology into a core plan

Docs
sched_setaffinity(2)

The system call behind every pinning library. Note that affinity is per thread, is inherited by threads you spawn afterwards, and that the mask can be overridden by cpusets.

Docs
NO_HZ: Reducing Scheduling-Clock Ticks

What nohz_full actually does (stops the periodic tick on CPUs running a single task), its requirements, and the housekeeping CPU it still needs.

Rust's standard library has no affinity API; use libc::sched_setaffinity directly (or a small crate such as core_affinity) from inside the thread you want to pin, and name your threads so tools show them. Pinning alone only says where your thread may run. To keep everything else off those cores:

  • isolcpus= removes CPUs from the scheduler's load balancing, so nothing lands there unless explicitly pinned. The kernel documents it as deprecated in favour of cpusets, but it remains very common on trading hosts; cpusets (or tuned's cpu-partitioning profile) achieve the same more flexibly.
  • nohz_full= stops the periodic scheduler tick on those CPUs while they run a single task, removing a regular interruption (with a 1 kHz tick, one every millisecond). rcu_nocbs= moves RCU callback processing off them.
  • Interrupts: set /proc/irq/*/smp_affinity (and irqaffinity= at boot) so device interrupts go to housekeeping cores, and stop irqbalance from undoing it. NIC queues are the exception you place deliberately (week 31).
  • SMT siblings share L1, L2 and execution units. Two hot threads on sibling hyperthreads slow each other down unpredictably; most latency setups leave the sibling of a hot core idle or disable SMT.
  • Housekeeping: something must run the OS, logging, metrics and the control plane. Reserve core 0 (and its sibling) for that.

Look at your machine before planning: lscpu -e lists every logical CPU with its core, socket and NUMA node; lstopo draws it; on many Intel servers CPU i and CPU i + N are siblings, but check /sys/devices/system/cpu/cpu0/topology/thread_siblings_list rather than assuming.

Drill

Plan a core layout for an exchange

Target: 7/7 cases passNot yet run
/// One logical CPU as Linux numbers it. SMT siblings (hyperthreads) share
/// the same `core`; `core` ids are unique across the whole machine.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Cpu {
    pub id: usize,
    pub core: usize,
    pub node: usize,
}

pub struct Topology {
    pub cpus: Vec<Cpu>,
    /// NUMA node the NIC is attached to (/sys/class/net/<if>/device/numa_node).
    pub nic_node: usize,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
    /// Busy-polling, latency-critical: engine, gateway rx, market-data publisher.
    Hot,
    /// Needs its own CPU but tolerates a busy sibling: journal writer, risk refresher.
    Warm,
    /// Housekeeping: metrics, logging, control plane.
    Cold,
}

#[derive(Clone, Debug)]
pub struct ThreadSpec {
    pub name: &'static str,
    pub role: Role,
}

/// Return one logical CPU id per thread (same order as `threads`), or
/// Err(reason) when no valid plan exists.
///
/// 1. The housekeeping core is the physical core containing CPU id 0. Every
///    Cold thread runs on one of its logical CPUs; nothing else may.
/// 2. Each Hot thread gets a whole physical core on `nic_node`, outside the
///    housekeeping core: no other thread on that CPU or its SMT siblings.
/// 3. Each Warm thread gets a logical CPU nobody else uses, outside the
///    housekeeping core and every Hot core. Prefer `nic_node`: a Warm thread
///    may sit on another node only if no eligible CPU on `nic_node` is left.
pub fn plan(topo: &Topology, threads: &[ThreadSpec]) -> Result<Vec<usize>, String> {
    todo!()
}
Rust
Recall#smt-siblings

You have one spare physical core with two hyperthreads. Why not run the gateway receive thread and the market-data publisher on the two siblings?

Day 4 — NUMA

Day 04

Memory has an address and a location

Local nodes, remote nodes and first touch

On a multi-socket server (and some large single-socket parts) memory is attached to NUMA nodes. A load from the local node's DRAM is roughly the same cost as on a single-socket machine; a load from a remote node crosses the socket interconnect and typically costs 1.5–2× as much. Cache-line transfers between cores on different sockets are similarly more expensive than within a socket.

Three rules for an exchange:

  • Put the hot path on the NIC's node. Packets are DMA'd into memory near the NIC; a gateway thread on the other socket pays the remote penalty on every packet.
  • Memory goes where it is first touched. Linux's default policy places a page on the node of the CPU that first writes it. Allocate and prefault each thread's data (week 27) from that thread, after it is pinned — not from main before spawning.
  • Keep pipelines on one socket. A queue whose producer and consumer are on different sockets pays cross-socket transfers for every cache line of every message.

numactl --hardware shows nodes and their distances; numactl --cpunodebind=1 --membind=1 runs a whole process on one node; numastat -p shows where a process's memory actually ended up.

Recall#first-touch

main() allocates and zeroes the engine's order slab, then spawns the engine thread pinned to a core on node 1. The machine has two nodes and main ran on node 0. What happens?

Design note#my-topology

Run lscpu -e (and lstopo if you have it) on the machine you benchmark on. Write down sockets, NUMA nodes, cores, SMT siblings and which node your NIC (or loopback-heavy workload) should live on. Then write the core plan for your exchange's threads by hand, before running your drill's planner on it.

0 chars

Day 5 — What still interrupts an isolated core

Day 05

Measuring jitter

Hiccups, interrupts and the price of spinning

Even a pinned, isolated, busy-polling thread is occasionally interrupted: device interrupts you did not move, inter-processor interrupts (TLB shootdowns when another thread of your process unmaps memory, for example), kernel worker threads, a residual timer tick, SMIs from firmware. Find them empirically:

  • Hiccup measurement. Run a thread on the isolated core that does nothing but read the clock in a tight loop and record every gap longer than, say, 1 µs into a histogram. That is the noise floor of the core — your engine can never be better. (Gil Tene's jHiccup applies the same idea to JVMs.)
  • Counters: perf stat -e context-switches,cpu-migrations -p <pid> should show zero of both for a pinned busy-polling thread; /proc/interrupts shows which interrupts still land on which CPUs.

Spinning has real costs, so choose it deliberately: a busy-polling thread uses 100% of its core forever, consumes power and thermal headroom (which can lower the turbo frequency other cores reach), and on SMT steals resources from its sibling. A typical exchange busy-polls the gateway receive path, the engine and the market-data publisher, and lets the journal writer, snapshotting, metrics and admin threads spin briefly and then park.

Recall#busy-poll-cost

Busy polling cut the engine's handoff latency from ~20 µs to ~200 ns. What are you paying for it, and when would you not do it?

Day 6 — Choosing a wait strategy per thread

Day 06

Latency, CPU, and honesty about both

One table you will defend in an interview

Design note#wait-strategies

For every thread in your exchange (gateway rx/tx, risk, engine, journal, market-data publisher, slow-consumer fan-out, metrics, admin), choose busy-poll, spin-then-park (with limits) or block, and assign it a core class from your plan. Justify each with its latency requirement and its expected idle fraction.

0 chars

Capstone

Make thread placement and waiting explicit in crates/sync, and measure what each choice costs.

What to build:

  • Pinning: a small utility that pins the calling thread to a CPU (libc::sched_setaffinity), verifies it with sched_getaffinity, and names the thread. The engine and gateway binaries take their core assignments from configuration — ideally produced by a planner like today's drill run against the real topology.
  • A wait strategy for the week 5 queues: busy-spin, spin-then-park (your drill, hardened), and plain blocking, selectable per consumer. The parking path must be free of lost wakeups by construction — SeqCst on the announce/re-check and publish/check pairs, a re-check before every park, a loop around every park.
  • A wakeup-latency benchmark: two pinned threads; the producer writes a timestamp into a message and publishes it; the consumer records now − timestamp into the week 25 histogram on receipt. Run it for each strategy with a gap between messages long enough that a spin-then-park consumer actually parks.

The repo's version lives entirely in crates/sync:

  • Pinning. pin_current_thread(cpu) -> io::Result<()> calls sched_setaffinity through its own two-function FFI declaration (no libc crate). Off Linux it does nothing and returns Ok(()). current_affinity() reads the mask back. Neither names the thread; std::thread::Builder::name does that. Nothing else in the repo pins threads yet, week 36's shard threads included, so taking core assignments from configuration is yours to wire up.
  • Wait strategies. A WaitStrategy trait: wait_for(ready, deadline) -> bool, wait_until(ready), and notify(), which the producer calls after publishing. It has three implementations: BusySpin, SpinYield::new(spins) and SpinPark::new(spins, max_park). There's no plain blocking strategy; spin-then-yield is the third point on the curve. SpinPark uses the fence version of the fix: Relaxed stores and loads with a SeqCst fence between each store and the following load, on both sides. It re-checks before every park and parks with park_timeout(max_park), so a notify that never comes (a cancellation flag nobody signals) is still noticed within max_park. It supports one waiting thread at a time.
  • The benchmarks. The main thread stamps the time, publishes a round number and calls notify. The waiter records now − stamp in a benchkit histogram when wait_until returns, then acks. w30.wakeup_busy runs BusySpin with a 1 µs pause before each round. w30.wakeup_park runs SpinPark::new(1_000, 100 ms), and before each round the producer waits until is_parked() is true, then 30 µs more, so every sample takes the parked path.
  • Placement. The benches pin the two threads with a helper of their own rather than week 30's function, so early weeks' benches don't depend on it. It picks one CPU per physical core, packed into the fewest L3 domains, skipping core 0 when there are enough cores. HFT_BENCH_CPUS=2,3 chooses the CPUs, and HFT_BENCH_CPUS=none turns pinning off.

The w30_ tests check that:

  • every strategy honours ready and the deadline;
  • every strategy wakes on a cross-thread signal;
  • SpinPark really parks, and notify unparks it;
  • SpinPark never loses a wakeup: with random producer pauses and max_park at 30 s, a lost notify trips a watchdog;
  • pin_current_thread sets the affinity mask.

The decisions that matter:

  • Which cores. Same socket, different physical cores, siblings idle — and write the layout into the report. Two threads on SMT siblings will look spectacularly fast (they share L1) and tell you nothing about production; the same core will look catastrophic.
  • The spin budget. Tune spin-then-park limits in time units on your CPU, not iteration counts copied from elsewhere; PAUSE latency varies by an order of magnitude across microarchitectures.
  • The environment. Wakeup-from-park latency is dominated by C-states and frequency scaling. Measure once on a default configuration and once with idle states limited and the performance governor, and report both.

About the targets: the busy-polling bound is a same-socket core-to-core transfer plus your queue's overhead, measured at p99 (the repo's benchmark hands over a bare atomic round number, so no queue cost is in it); the park/unpark bound is two orders of magnitude larger because it includes a futex wake, a scheduler pass and possibly an idle-state exit. If busy polling misses its target, check placement first (siblings, cross-socket, a shared core). If park misses, look at C-states and at whether the consumer core is isolated — a parked thread whose core is busy with something else waits for the scheduler.

Capstone · in your exchange · crates/sync

Pinned threads and measured wakeups

Engine and gateway threads pinned to planned cores, a spin-then-park wait strategy without lost wakeups, and a wakeup-latency benchmark comparing busy polling with park/unpark.

cargo xtask test --week 30      # tests named w30_*
cargo xtask bench --week 30     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 30 tests pass w30_*
  • Cross-thread wakeup, busy polling w30.wakeup_busytarget p99 ≤ 200 ns
  • Cross-thread wakeup, park/unpark w30.wakeup_parktarget p99 ≤ 100 µs

Feeds into

  • Week 36 shards the engine across several engine threads; the core plan and pinning from this week decide where each shard runs and keep each shard's router, engine and publisher on the same socket. The repo's ShardedEngine doesn't pin its threads yet, and links them with std::sync::mpsc. Its module docs say where the sync crate's busy-polled SPSC ring would replace those channels.
  • The same placement rules come back in week 31 (where the NIC's interrupts and queues go) and in week 47's host-tuning checklist.