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
The path of a wakeup
Futexes, schedulers and idle states
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.
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.
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
A wait strategy that is cheap when busy and correct when idle
Drill 1: spin-then-park without losing a notification
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.
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:
- Spin with
std::hint::spin_loop()— the x86PAUSEinstruction, 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. - Yield with
thread::yield_now()— asched_yieldsyscall that lets another runnable thread on this CPU run, and returns immediately if there is none. - 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 checkready; if not ready, park. - Notifier:
ready = true, then checksleeping; 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.
x = y = 0x.store(1, Relaxed); let r1 = y.load(Relaxed);
y.store(1, Relaxed); let r2 = x.load(Relaxed);
r1 == 0 && r2 == 0 happen?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.
Spin-then-park backoff
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)
}
}
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
Give every hot thread a core of its own
Drill 2: turn a topology into a core plan
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.
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 (ortuned'scpu-partitioningprofile) 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(andirqaffinity=at boot) so device interrupts go to housekeeping cores, and stopirqbalancefrom 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.
Plan a core layout for an exchange
/// 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!()
}
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
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
mainbefore 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.
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?
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.
Day 5 — What still interrupts an isolated core
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/interruptsshows 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.
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
Latency, CPU, and honesty about both
One table you will defend in an interview
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.
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 withsched_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 —
SeqCston 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 − timestampinto 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<()>callssched_setaffinitythrough its own two-function FFI declaration (nolibccrate). Off Linux it does nothing and returnsOk(()).current_affinity()reads the mask back. Neither names the thread;std::thread::Builder::namedoes 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
WaitStrategytrait:wait_for(ready, deadline) -> bool,wait_until(ready), andnotify(), which the producer calls after publishing. It has three implementations:BusySpin,SpinYield::new(spins)andSpinPark::new(spins, max_park). There's no plain blocking strategy; spin-then-yield is the third point on the curve.SpinParkuses the fence version of the fix:Relaxedstores and loads with aSeqCstfence between each store and the following load, on both sides. It re-checks before every park and parks withpark_timeout(max_park), so a notify that never comes (a cancellation flag nobody signals) is still noticed withinmax_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 recordsnow − stampin abenchkithistogram whenwait_untilreturns, then acks.w30.wakeup_busyrunsBusySpinwith a 1 µs pause before each round.w30.wakeup_parkrunsSpinPark::new(1_000, 100 ms), and before each round the producer waits untilis_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,3chooses the CPUs, andHFT_BENCH_CPUS=noneturns pinning off.
The w30_ tests check that:
- every strategy honours
readyand the deadline; - every strategy wakes on a cross-thread signal;
SpinParkreally parks, andnotifyunparks it;SpinParknever loses a wakeup: with random producer pauses andmax_parkat 30 s, a lost notify trips a watchdog;pin_current_threadsets 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;
PAUSElatency 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
performancegovernor, 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.
crates/syncPinned 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
ShardedEnginedoesn't pin its threads yet, and links them withstd::sync::mpsc. Its module docs say where thesynccrate'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.