Parallel Performance

Thread Affinity: Pinning, and What It Costs You

Tell the scheduler a thread may only run on certain cores. It removes migration, keeps caches warm and cuts latency variance — and it hands you a scheduling decision the OS was making better than you will, on hardware you may not be running on next quarter.

The question this answers

The question

Should I pin these threads to specific cores, and what am I giving up if I do?

The work

Four market-data handler threads on a 16-core machine that must respond within a tight, predictable budget — and, as the counter-example, a general web service's request-handling pool on the same hardware.

What is shared

The cores themselves, which every runnable thread on the machine competes for. Pinning does not remove that sharing; it fixes *which* threads compete for which cores, which is a policy decision with winners and losers.

The invariant — what must stay true under every interleaving

Every task still runs to completion and produces the same result, on any core. Affinity constrains where work may run; it never changes what the work computes — which is why an affinity bug shows up as a latency or throughput regression, never as a wrong answer.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

What pinning actually buys

The scheduler moves threads between cores to balance load. Each move discards the thread's warm private cache and address-translation state, and on a multi-socket machine a move across sockets makes its whole working set remote (NUMA: Not All Memory Costs the Same). Pinning a thread to one core removes those moves. What you get is not usually higher average throughput — it is *lower variance*, because the intermittent cost of a migration is gone.

That distinction decides whether pinning is worth considering at all. If your requirement is "the mean is fine but p99 is four times p50 and we do not know why", removing migration is a plausible and testable lever. If your requirement is "we need more total throughput", pinning is close to irrelevant and you should be looking at Memory Bandwidth: More Cores, Same Bus, the serial fraction, or contention instead.

Pinning also composes with other placement decisions in a way that makes it powerful in narrow settings: pin the thread to a core, allocate its data on that core's NUMA node, and keep other work off that core, and you have a thread whose access costs are essentially constant. That combination is how low-latency trading systems, some kernel-bypass network stacks and some real-time media pipelines get their numbers. It is also three separate configuration decisions that must all hold in production, which is the real price.

SituationPin?WhyWhat you give up
Latency-critical handler, dedicated machineYesRemoves migration jitter; caches stay warmFlexibility; that core is committed
Long-running worker with a large working setMaybeMigration cost is proportional to the footprint discardedLoad balancing across cores
NUMA-bound job with node-local dataMaybeGuarantees the thread stays with its memoryA whole socket may sit idle under uneven load
Poller / busy-wait loopYes, with isolationA spinning thread must not share a core with anythingA dedicated core, permanently, doing one thing
General web service request poolNoBursty and uneven; the scheduler balances better than youNothing — do not do it here
Container with a CPU quotaNoPinning to cores the quota does not grant creates throttlingNothing — the quota is the real constraint
Anything that runs on hardware you do not controlNoCore numbering and topology are not portableNothing — it will silently misconfigure
When pinning earns its keep, and when it is a liability.

The shape of doing it, and the three things people get wrong

The API shape is the same everywhere: build a set of allowed CPUs and apply it to a thread. The sketch below is pseudocode because the real call differs by OS and the difference is not the interesting part — the interesting part is the three mistakes that surround it, all of which are about the *numbers* you put in the set.

First: logical CPU ids are not portable and not necessarily contiguous. Two logical ids may be hyperthread siblings on one physical core, so pinning four threads to CPUs 0-3 can put all four on two physical cores and halve your throughput while looking correct. Query the topology; never hard-code the numbering.

Second: pinning inside a container is usually wrong. The container sees the host's CPUs but is limited by a quota, so pinning to specific host cores can pin you to cores your quota does not actually grant, producing throttling that is very hard to diagnose. Third: pinning without *isolating* is half a measure — if you pin your latency-critical thread to core 5 and the OS keeps scheduling other work there, you have removed your thread's ability to move away from interference without removing the interference. Real isolation means keeping other threads and interrupts off that core, which is a system-configuration task, not an application one.

  • Query topology; a logical CPU id may be a hyperthread sibling, and the numbering is not portable.
  • Do not pin inside a CPU-quota container: the quota, not the core set, is the constraint.
  • Pinning without isolation removes your thread's escape route without removing the interference.
  • Ship it behind a flag and compare p99 both ways, or you will never know whether it helped.
1# 1. Query the topology. NEVER hard-code core numbers.
2topology = query_cpu_topology() # physical cores, sibling threads, numa node
3cores = topology.physical_cores_on_node(0)
4assert len(cores) >= 4, "not enough physical cores; do not pin"
5
6# 2. Refuse to pin when the environment makes it meaningless or harmful.
7if running_in_container() and cpu_quota() < len(cores):
8 log.warn("cpu quota is the real limit; skipping affinity")
9 return # unpinned is better than mis-pinned
10
11# 3. One handler per PHYSICAL core, not per logical cpu.
12for i, handler in enumerate(handlers):
13 set_thread_affinity(handler.tid, {cores[i].primary_logical_id})
14
15# 4. Pinning without isolation is half a measure. These are system
16# configuration, not application code:
17# - keep the general scheduler off these cores
18# - route device interrupts elsewhere
19# - allocate each handler's buffers on this node (first touch)
20
21# 5. Make it switchable and measure both ways. If p99 does not improve,
22# remove it -- you are paying complexity for nothing.
The shape of pinning — and the checks that matter more than the call

What you gave the scheduler up for

The cost is the flip side of the benefit, and the timeline shows it. The OS scheduler has global information — every runnable thread on the machine, every core's current load — and it rebalances continuously. Pinning replaces that with a static assignment made by you, at deploy time, from a much worse vantage point.

When load is even, the static assignment is fine and the migration savings are real. When load is uneven — one pinned thread has a burst of work while another is idle — the pinned thread waits in the ready state on its own core while an idle core sits next to it doing nothing. The unpinned version would simply have run there. That is not a subtle cost: it is a core's worth of throughput, unavailable, visible in the timeline as ready time next to an idle lane.

This is why the honest recommendation is narrow. Pin when the workload is steady, the machine is dedicated, latency variance is the actual complaint, and you can measure the difference. Everywhere else, the scheduler's global view beats your static one, and the configuration you would have to keep correct — topology, isolation, interrupt routing, NUMA placement — is a standing maintenance cost that follows the service to every new machine type it lands on.

Uneven load. The pinned thread waits next to an idle core.ILLUSTRATIVE
Pinned handler A -> core 4 (burst of work)
running
ready — core 4 busy with its own queue
running
Pinned handler B -> core 6 (quiet period)
running
idle — no work for this handler
running
Unpinned equivalent — scheduler rebalances
running on core 4
migrated: running on core 6
running on core 6
↑ load goes uneven — the pinned pair cannot adapt↑ pinned A waited 5 units; unpinned did the work
runningreadywaitingblockedidle1 unit ~ one scheduler quantum

Key points

  • Pinning removes migration, which mainly buys lower latency variance rather than higher average throughput.
  • It is worth considering when p99 is the complaint, the machine is dedicated, and the load is steady.
  • Logical CPU ids are not portable and may be hyperthread siblings — query the topology, never hard-code numbers.
  • Pinning inside a CPU-quota container is usually harmful, because the quota rather than the core set is the real constraint.
  • Pinning without isolating the core is half a measure: you removed the escape route without removing the interference.
  • The cost is real and immediate under uneven load — a pinned thread waits while an idle core sits next to it.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • Each thread carries an affinity mask: the set of logical CPUs the scheduler may place it on.
  • By default the mask is every CPU, and the scheduler migrates threads freely to balance load.
  • Setting the mask constrains placement; the scheduler will queue the thread on an allowed CPU rather than move it to a free disallowed one.
  • With one thread per core and other work kept off those cores, the thread effectively owns the core, its private caches and its translation state.
  • Combined with node-local allocation (NUMA: Not All Memory Costs the Same), memory access cost for that thread becomes near-constant, which is the whole point in latency-critical systems.
Interleavings that matter
  • Uneven load: A has work and is pinned to core 4; B is idle and pinned to core 6; A queues behind its own backlog while core 6 idles. Unpinned, A would have run on 6 immediately.
  • Sibling collision: threads pinned to logical CPUs 0,1,2,3 land on two physical cores because 0/1 and 2/3 are siblings; throughput halves while the configuration looks symmetric and correct.
  • Quota interaction: a container is pinned to host cores 8-11 but granted a 2-core quota; threads on all four cores are throttled in alternating windows, producing latency spikes that look like GC pauses.
  • Pinned without isolation: the OS schedules a housekeeping thread and a device interrupt onto core 4; the pinned handler is preempted and, unlike an unpinned thread, cannot escape to a quieter core.
  • Priority inversion, made worse: a pinned high-priority thread waits on a lock held by a lower-priority thread pinned to the same core, and the holder cannot be scheduled elsewhere to release it (Priority Inversion).
What it guarantees — and does not
  • Guarantees the thread runs only on CPUs in its mask — the one thing affinity actually promises.
  • Guarantees no migration among cores outside the mask, so private cache and translation state survive across quanta on that core.
  • Does NOT guarantee exclusive use of the core. Other threads and interrupts can still be scheduled there unless the system is configured to keep them off.
  • Does NOT guarantee lower latency. It removes one source of variance; if your variance comes from GC, allocation, lock contention or I/O, it changes nothing.
  • Does NOT guarantee the mask means what you think on a different machine. Core numbering, sibling layout and node membership are all machine-specific.
  • Does NOT interact well with CPU quotas: the mask and the quota are enforced by different mechanisms and can contradict each other.
Where contention appears
  • Pinning does not reduce contention for a core; it fixes which threads contend for which core, and can concentrate contention where the scheduler would have spread it.
  • Two threads pinned to sibling logical CPUs of one physical core contend for that core's execution resources while appearing to have a core each.
  • A pinned busy-wait loop consumes its core completely and starves anything else assigned there — busy-waiting and pinning must be adopted together or not at all (Busy Waiting).
  • Under uneven load, pinning creates queueing on busy cores that idle cores cannot absorb, which is contention you manufactured.
How it fails
  • Throughput loss under uneven load: pinned threads queue while other cores idle.
  • Silent halving from sibling collisions when logical ids were assumed to be distinct physical cores.
  • Container throttling from a mask that disagrees with the CPU quota, presenting as unexplained periodic latency spikes.
  • Configuration rot: the pinning was tuned for one machine type and is quietly wrong on the next one, with no error at any layer.
  • Starvation of housekeeping threads pinned out of the cores they need.
  • Worsened priority inversion, because the lock holder cannot be scheduled onto another core to make progress.
When it helps
  • Latency-critical, steady workloads on dedicated hardware — trading systems, kernel-bypass packet processing, real-time audio and video pipelines.
  • Dedicated poller threads that busy-wait, which must own a core to be anything other than harmful.
  • Large-working-set workers on NUMA machines, where staying with their memory matters more than load balancing.
  • Benchmarking, where removing migration reduces run-to-run variance and makes small differences measurable — a genuinely good use even when production stays unpinned.
When it hurts
  • Bursty, uneven workloads — most services — where the scheduler's global view beats a static assignment every time.
  • Containers and orchestrated environments, where CPU allocation is enforced by quota and the machine you land on is not the one you tuned for.
  • Any deployment across heterogeneous hardware, where a fixed mask is right on one machine type and wrong on the rest.
  • When the real variance source is elsewhere: garbage collection, lock contention, I/O or a downstream dependency. Pinning cannot help and will be blamed for not helping.
  • When it is adopted as a default "performance best practice" without a measurement, which is how most of it happens.
How you would know
  • p99 and p999 latency with and without pinning, on the same hardware and the same load. If the tail does not improve, remove it.
  • Thread migration count and involuntary context switches per second, before and after — the direct evidence that pinning did the thing it claims.
  • Per-core utilization while pinned: a busy core beside an idle one is the cost of pinning, visible immediately.
  • Container CPU throttling counters, which catch the mask-versus-quota conflict that otherwise looks like random stalls.
  • Physical versus logical core mapping at startup, logged. It is the cheapest possible guard against the sibling-collision mistake.
Complexity it introduces
  • You now own a topology-dependent configuration that must be correct on every machine type the service is deployed to, and that fails silently when it is not.
  • Real benefit requires isolation and interrupt routing, which are host-level configuration outside the application and outside its deployment artifact.
  • Capacity planning gets harder: pinned threads cannot absorb each other's bursts, so headroom must be provisioned per core rather than per machine.
  • It interacts with NUMA placement, CPU quotas, hyperthreading and the runtime's own thread pools — four systems that must agree, none of which validate each other.
  • It is difficult to test: the effect appears only on production-like hardware under production-like load.
Simpler alternatives
  • Do nothing. The scheduler is good, and for the overwhelming majority of services unpinned is the right answer.
  • Size the pool to the core count so oversubscription (the actual source of most migration) disappears without constraining placement (Sizing a Thread Pool).
  • Use the container runtime's CPU-set support instead of application-level pinning, so the constraint is declared where the allocation is made.
  • Fix the actual variance source first — allocation rate, GC configuration, lock contention, a slow dependency — all of which are usually larger than migration jitter.
  • Improve locality instead of constraining placement: smaller working sets and contiguous chunking reduce what a migration costs (Parallelism Can Destroy Locality).

What people believe, and what is true

Claim

Pinning makes threads faster.

Reality

It removes migration. That reduces variance and can help a warm-cache workload; it does not add throughput, and under uneven load it removes throughput.

Claim

Pinning to CPUs 0-3 gives me four cores.

Reality

It gives you four *logical* CPUs, which may be two physical cores with two hyperthread siblings each. Query the topology before choosing ids.

Claim

We pinned the thread, so nothing else runs on that core.

Reality

Other threads and interrupts still land there unless the system is configured to exclude them. Pinning constrains your thread, not everyone else's.

Claim

Pinning is a good default for performance-sensitive services.

Reality

It is a good default for steady workloads on dedicated hardware and a liability nearly everywhere else, especially inside CPU-quota containers.

Apply it