CPU Affinity and sched_setaffinity
CPU affinity is the kernel-enforced constraint that a task may run only on a specified subset of the machine’s CPUs. Each task carries an affinity mask,
task_struct::cpus_mask, a bitmap of CPU numbers it is allowed to occupy; the scheduler honors it everywhere it decides where a task runs — at wakeup placement, at load balancing, at fork. Userspace sets the mask with thesched_setaffinity(2)system call (or thetaskset(1)wrapper) and reads it back withsched_getaffinity(2). The subtlety that dominates this note is that since Linux 5.x–6.x the kernel keeps two masks: the user-requested mask (what you asked for, stored inuser_cpus_ptr) and the effective mask (cpus_mask/cpus_ptr, what the task is actually allowed on after intersecting your request with the constraints of its cpuset). This split — landed by Will Deacon’suser_cpus_ptrintroduction (commitb90ca8badbd1, 2021) and Waiman Long’s “Always preserve the user requested cpumask” (commit8f9ea86fdf99, 2022) — is why an affinity request can survive a cpuset that temporarily takes CPUs away and reappear when they come back. Everything below is verified against the 6.12 LTS source tree (released 2024-11-17); the affinity machinery is unchanged in 6.18 LTS.
Mental Model
Think of affinity as a filter applied to the set of CPUs the scheduler is willing to consider for a task. The task’s task_struct holds the filter as a cpumask_t bitmap. When any scheduler code asks “may this task run on CPU n?”, it consults this mask. The filter is enforced at three moments: when a sleeping task wakes up and the scheduler picks a CPU for it (wakeup placement via select_task_rq), when the periodic load balancer considers pulling a task to a less-busy CPU, and when a CPU goes offline and the task must be evicted somewhere legal.
The crucial mental refinement for modern kernels is that the mask you set is not necessarily the mask that takes effect. Your request is filtered a second time through the task’s cgroup cpuset: the effective mask is the intersection of what you asked for and what the cpuset permits. The kernel remembers your raw request separately so it can recompute the effective mask whenever the cpuset boundary moves.
flowchart TD U["userspace: sched_setaffinity(pid, mask)<br/>'I want CPUs {2,3,4,5}'"] --> SYS["SYSCALL_DEFINE3(sched_setaffinity)<br/>copy_from_user → new_mask"] SYS --> SSA["sched_setaffinity()<br/>save request → user_cpus_ptr<br/>flags = SCA_USER"] SSA --> US["__sched_setaffinity()<br/>cpuset_cpus_allowed(p) → {2,3,4,5,6,7}<br/>new_mask = request ∩ cpuset"] US --> EFF["effective mask {2,3,4,5}<br/>written to p->cpus_mask"] EFF --> PTR["p->cpus_ptr normally aliases &p->cpus_mask"] PTR --> STR["select_task_rq / load balance<br/>only ever pick a CPU in cpus_ptr"] CS["cpuset shrinks to {4,5}"] -.recompute.-> US2["effective recomputed:<br/>request {2,3,4,5} ∩ {4,5} = {4,5}<br/>request preserved in user_cpus_ptr"]
Two-stage filtering of an affinity request. What it shows: the syscall stores the raw request in user_cpus_ptr, then __sched_setaffinity() intersects it with the task’s cpuset-allowed set to produce the effective cpus_mask that the scheduler actually obeys. The insight to take: the request and the effective mask are distinct; when a cpuset later shrinks (dashed path), the effective mask is recomputed from the preserved request, so the task gives up CPUs gracefully and reclaims them if the cpuset expands again — you never silently lose your original intent.
The task_struct Fields
In include/linux/sched.h at v6.12, the affinity state of every task is four adjacent fields (sched.h v6.12, lines 877–883):
int nr_cpus_allowed; /* popcount of cpus_mask, cached */
const cpumask_t *cpus_ptr; /* the mask the scheduler reads */
cpumask_t *user_cpus_ptr; /* the user-requested mask, or NULL */
cpumask_t cpus_mask; /* the effective allowed mask */
void *migration_pending;
...
unsigned short migration_disabled;Walking the fields:
cpus_maskis the effective affinity — the actual bitmap of CPUs the task may run on right now, after every constraint has been applied. It is acpumask_tembedded directly intask_struct(not a pointer), so it always exists.cpus_ptris aconst cpumask_t *that the scheduler reads through whenever it needs the allowed set. In the normal casecpus_ptr == &cpus_mask— the pointer simply aliases the embedded mask. The indirection exists so the kernel can temporarily redirect the task to a single-CPU mask during a migrate-disable critical section (see below) without clobberingcpus_mask. This is the load-bearing detail: scheduler hot paths dereferencecpus_ptr, notcpus_mask, precisely so migrate-disable can swap the pointer cheaply and restore it afterward.user_cpus_ptrisNULLuntil userspace callssched_setaffinity(), at which point it points at a heap-allocated copy of the raw user request. This is the mask the user typed, before cpuset intersection. It lets the kernel recomputecpus_maskif the cpuset boundary moves.nr_cpus_allowedis the cached population count ofcpus_mask. The scheduler checksnr_cpus_allowed > 1as a fast gate before bothering to choose a CPU — a task pinned to exactly one CPU skips placement logic entirely.
Mechanical Walk-through: Setting Affinity
When userspace calls sched_setaffinity(pid, cpusetsize, mask), the kernel enters SYSCALL_DEFINE3(sched_setaffinity, …) in kernel/sched/syscalls.c (syscalls.c v6.12, line 1320). It allocates a kernel-side cpumask_var_t, copies the user bitmap in via get_user_cpu_mask(), and calls sched_setaffinity(pid, new_mask).
The inner sched_setaffinity() does the permission and bookkeeping work:
long sched_setaffinity(pid_t pid, const struct cpumask *in_mask)
{
struct affinity_context ac;
struct cpumask *user_mask;
...
if (p->flags & PF_NO_SETAFFINITY) /* kernel per-CPU threads refuse */
return -EINVAL;
if (!check_same_owner(p)) { /* not your task? */
if (!ns_capable(__task_cred(p)->user_ns, CAP_SYS_NICE))
return -EPERM; /* need CAP_SYS_NICE */
}
...
user_mask = alloc_user_cpus_ptr(NUMA_NO_NODE);
if (user_mask)
cpumask_copy(user_mask, in_mask); /* snapshot the raw request */
...
ac = (struct affinity_context){
.new_mask = in_mask,
.user_mask = user_mask,
.flags = SCA_USER, /* "this is a user request" */
};
retval = __sched_setaffinity(p, &ac);
kfree(ac.user_mask);
return retval;
}Line by line: PF_NO_SETAFFINITY is set on per-CPU kernel threads that must stay on their CPU; affinity changes to them are refused with -EINVAL. If the caller does not own the target task, it needs the CAP_SYS_NICE capability or the call fails -EPERM. The function then snapshots the raw request into a freshly allocated user_mask, packages everything into a struct affinity_context with the SCA_USER flag set — that flag is how the lower layers know this is an explicit user request (versus an internal kernel rebalance) and therefore should update user_cpus_ptr — and hands off to __sched_setaffinity().
The struct affinity_context (defined in kernel/sched/sched.h) and its SCA_* flags are the internal vocabulary of every affinity change:
struct affinity_context {
const struct cpumask *new_mask; /* the mask to install */
struct cpumask *user_mask; /* new user_cpus_ptr, if SCA_USER */
unsigned int flags;
};
#define SCA_CHECK 0x01 /* honor PF_NO_SETAFFINITY etc. */
#define SCA_MIGRATE_DISABLE 0x02 /* entering a migrate-disable region */
#define SCA_MIGRATE_ENABLE 0x04 /* leaving one */
#define SCA_USER 0x08 /* a userspace request; update user_cpus_ptr */The intersection: __sched_setaffinity
This is where the user request becomes the effective mask (syscalls.c v6.12, line 1194):
int __sched_setaffinity(struct task_struct *p, struct affinity_context *ctx)
{
cpumask_var_t cpus_allowed, new_mask;
...
cpuset_cpus_allowed(p, cpus_allowed); /* what the cpuset permits */
cpumask_and(new_mask, ctx->new_mask, cpus_allowed); /* request ∩ cpuset */
ctx->new_mask = new_mask;
ctx->flags |= SCA_CHECK;
retval = dl_task_check_affinity(p, new_mask); /* deadline-task admission */
if (retval) goto out;
retval = __set_cpus_allowed_ptr(p, ctx); /* install the mask */
...
cpuset_cpus_allowed(p, cpus_allowed);
if (!cpumask_subset(new_mask, cpus_allowed)) { /* raced a cpuset change? */
cpumask_copy(new_mask, cpus_allowed); /* fall back to cpuset's set */
...
__set_cpus_allowed_ptr(p, ctx);
retval = -EINVAL;
}
...
}cpuset_cpus_allowed() (in kernel/cgroup/cpuset.c) returns the set of CPUs the task’s cpuset currently permits. cpumask_and() intersects that with the user’s request to form new_mask — this intersection is the entire reason the effective mask can differ from what you asked for. If the task is a SCHED_DEADLINE task, dl_task_check_affinity() enforces that the new mask still covers the task’s deadline root domain (you cannot strand a deadline task off its admitted CPUs). __set_cpus_allowed_ptr() then writes new_mask into the task and, because SCA_USER is set, swaps the saved user_mask into p->user_cpus_ptr. The closing re-check handles a race where the cpuset changed concurrently: if the just-installed mask is no longer a subset of the cpuset’s allowed CPUs, the kernel resets to the cpuset’s set and returns -EINVAL.
Installing the mask: set_cpus_allowed_common
__set_cpus_allowed_ptr() ultimately calls set_cpus_allowed_common() (core.c v6.12, line 2644):
void set_cpus_allowed_common(struct task_struct *p, struct affinity_context *ctx)
{
if (ctx->flags & SCA_MIGRATE_DISABLE) {
__do_set_cpus_allowed(p, ctx);
return;
}
cpumask_copy(&p->cpus_mask, ctx->new_mask); /* the effective mask */
p->nr_cpus_allowed = cpumask_weight(ctx->new_mask);
if (ctx->flags & SCA_USER)
swap(p->user_cpus_ptr, ctx->user_mask); /* persist the request */
}The effective bitmap lands in p->cpus_mask, nr_cpus_allowed is recomputed, and — only when SCA_USER is set — the user’s raw request is swapped into p->user_cpus_ptr. The old user_cpus_ptr (now in ctx->user_mask) is freed by the caller. This is the precise point where the “two masks” design is realized: internal kernel callers (load balancing, hotplug) set only cpus_mask; userspace requests additionally update user_cpus_ptr.
How Affinity Constrains Placement
Affinity is meaningless unless the scheduler obeys it. The single chokepoint is select_task_rq() (core.c v6.12):
int select_task_rq(struct task_struct *p, int cpu, int *wake_flags)
{
if (p->nr_cpus_allowed > 1 && !is_migration_disabled(p)) {
cpu = p->sched_class->select_task_rq(p, cpu, *wake_flags);
*wake_flags |= WF_RQ_SELECTED;
} else {
cpu = cpumask_any(p->cpus_ptr); /* pinned: just pick the one */
}
if (unlikely(!is_cpu_allowed(p, cpu)))
cpu = select_fallback_rq(task_cpu(p), p);
return cpu;
}If nr_cpus_allowed == 1, the task is pinned and the scheduler short-circuits to cpumask_any(p->cpus_ptr) — no placement decision is made. Otherwise the scheduling class’s own select_task_rq (for fair tasks, the EEVDF wakeup balancer) chooses among CPUs, and that chooser is itself constrained to p->cpus_ptr — it never proposes a CPU outside the mask. As a final guard, is_cpu_allowed() re-checks the chosen CPU; if it is somehow illegal (offline, not in the mask), select_fallback_rq() finds a legal one. is_cpu_allowed() begins with task_allowed_on_cpu(), which tests membership in the mask — affinity is the first gate. The same cpus_ptr constraint governs the periodic load balancer: it will not migrate a task to a CPU outside its mask.
Reading affinity back, sched_getaffinity() returns cpumask_and(mask, &p->cpus_mask, cpu_active_mask) — the effective mask intersected with currently-active CPUs. Note this returns cpus_mask, not user_cpus_ptr: you read back what is in effect, not what you originally requested. If a cpuset has shrunk your effective set, sched_getaffinity reflects the shrunken set even though the kernel still remembers your broader request internally.
Migrate-Disable and the cpus_ptr Indirection
The cpus_ptr/cpus_mask split exists for migrate-disable, a PREEMPT_RT-era mechanism letting a task pin itself to its current CPU without holding a non-preemptible section. When a task enters migrate-disable, migrate_disable_switch() redirects p->cpus_ptr to a single-CPU mask (the current CPU) via __do_set_cpus_allowed(p, {.flags = SCA_MIGRATE_DISABLE}), leaving cpus_mask untouched. On migrate_enable(), the code restores p->cpus_ptr = &p->cpus_mask and, if the real affinity changed meanwhile, re-applies it (core.c v6.12, lines 2296–2362). Because every scheduler read goes through cpus_ptr, the task is transparently glued to one CPU for the duration with a single pointer swap. This is why the scheduler dereferences cpus_ptr rather than &cpus_mask directly.
Persistence Across cpuset Changes
The reason for user_cpus_ptr is captured by Waiman Long’s commit 8f9ea86fdf99 “sched: Always preserve the user requested cpumask” (2022-09-22): “user provided CPU affinity via sched_setaffinity(2) is preserved even if the task is being moved to a different cpuset” (LWN 909123, commit 8f9ea86fdf99). Concretely: you pin a task to CPUs {2,3,4,5}; later the task’s cpuset is narrowed to {4,5}. Without the saved request the kernel would have to overwrite cpus_mask to {4,5} and forget you ever wanted {2,3}. With user_cpus_ptr holding {2,3,4,5}, the kernel recomputes the effective mask as the intersection {4,5} but keeps your request; if the cpuset later widens back to {2,3,4,5,6,7}, the task’s effective mask is recomputed as {2,3,4,5} again — you reclaim exactly the CPUs you asked for, no more, no less.
The kernel functions that drive this are force_compatible_cpus_allowed_ptr() (restrict a task’s affinity to a subset and stash the old mask in user_cpus_ptr) and relax_compatible_cpus_allowed_ptr() (restore the saved request once the constraint lifts), both in core.c. The original use case, from Will Deacon’s commit b90ca8badbd1 (2021), was asymmetric arm64: some 32-bit applications can only run on the subset of cores that support the AArch32 instruction set, so the kernel must transparently restrict their affinity while remembering the broader 64-bit request (commit b90ca8badbd1). The cpuset use case was layered on top later.
Userspace: cpu_set_t and taskset
The C API in <sched.h> represents a mask as the opaque cpu_set_t, manipulated by macros (sched_setaffinity(2)):
#define _GNU_SOURCE
#include <sched.h>
cpu_set_t set;
CPU_ZERO(&set); /* clear all bits */
CPU_SET(2, &set); /* allow CPU 2 */
CPU_SET(3, &set); /* allow CPU 3 */
if (sched_setaffinity(0, sizeof(set), &set) == -1) /* 0 = calling thread */
perror("sched_setaffinity");
CPU_ZERO(&set);
sched_getaffinity(0, sizeof(set), &set);
if (CPU_ISSET(2, &set)) puts("running set may include CPU 2");CPU_ZERO/CPU_SET/CPU_CLR/CPU_ISSET are the bitmap accessors; the glibc cpu_set_t is a fixed 128-byte structure covering CPUs 0–1023. On machines with more than 1024 CPUs the static type is too small and sched_setaffinity returns EINVAL; you must allocate a dynamic mask with CPU_ALLOC(3) and pass its actual byte size as cpusetsize. The kernel side uses variable-length unsigned long * bitmaps, so the syscall’s cpusetsize/len argument is the negotiation point between the two.
The taskset(1) command is the CLI wrapper (taskset(1)):
taskset -c 2,3 ./myprog # launch myprog pinned to CPUs 2 and 3
taskset -pc 0-3 1234 # repin running PID 1234 to CPUs 0-3 (-c = list form)
taskset -pc 1234 # read PID 1234's current affinity as a list
taskset -apc 0,1 1234 # -a: apply to ALL threads of PID 1234-c/--cpu-list accepts human ranges (0-3, 0,5,8-11, stride 0-10:2) instead of a hex bitmask; -p operates on an existing PID; -a applies to every thread of the process rather than the single thread named by the PID. Internally taskset is just sched_setaffinity/sched_getaffinity.
Hard vs Soft Affinity — and Why There Is No SCHED_FLAG For It
The affinity described here is hard: a CPU outside the mask is forbidden, full stop, even if every allowed CPU is saturated and other CPUs sit idle. A long-proposed alternative is soft affinity, where the mask is a preference — the task runs on its preferred CPUs when they are available but is permitted to spill onto idle CPUs elsewhere under contention. Jonathan Corbet’s 2019 LWN write-up describes a patch set adding a sched_setaffinity2() syscall with SCHED_HARD_AFFINITY and SCHED_SOFT_AFFINITY flags (LWN 792502).
That work was never merged. As of 6.12 LTS there is no soft-affinity flag — not in sched_setaffinity, not as a SCHED_FLAG_* on sched_setattr(2). The reviewers found the performance case unconvincing (gains under 7%, noisy data) and questioned whether improving AutoNUMA would be the better lever. So the only affinity primitive userspace has is the hard mask of sched_setaffinity. The closest thing to a “preference” the kernel actually ships is the wakeup-placement heuristics inside the fair-class balancer, which prefer cache-warm CPUs but are not user-controllable per task.
Uncertain
Verify: that no soft-affinity
SCHED_FLAG_*orsched_setaffinity2()exists in 6.18 LTS (this note confirms its absence in 6.12 and that the 2019 proposal was rejected, but does not re-scan 6.18’suapi/linux/sched.hflag list). Reason: soft affinity is a recurring proposal that could in principle reappear; checked 6.12 source and 2019 LWN, not 6.18 uapi headers. To resolve: grepSCHED_FLAG_ininclude/uapi/linux/sched.hat thev6.18tag. uncertain
Failure Modes and Common Misunderstandings
EINVALfrom an empty intersection. If your requested mask shares no CPU with the task’s cpuset-allowed set (or with online CPUs), the intersection is empty andsched_setaffinityreturnsEINVAL(“contains no processors that are currently physically on the system and permitted to the thread”). The affinity is left unchanged.- “My affinity didn’t take” — silent cpuset clamping. You ask for
{0-15}but the task is in a cpuset limited to{8-15}; the syscall succeeds andsched_getaffinityreports{8-15}. The kernel “silently imposes” the cpuset restriction (cpuset(7)). This is not a bug — it is the intersection by design. The full request still lives inuser_cpus_ptr. EPERMsetting another user’s task. Changing affinity on a task you do not own requiresCAP_SYS_NICE; without it you getEPERM.EINVALon>1024-CPU machines with staticcpu_set_t. The glibc fixed mask cannot express CPU numbers above 1023; useCPU_ALLOC.- Pinning a kernel per-CPU thread fails. Threads with
PF_NO_SETAFFINITY(per-CPU kthreads, the migration thread) refuse affinity changes withEINVAL. - Confusing affinity with isolation. Pinning a task to a CPU does not keep other tasks off it. To get a CPU to yourself you must also keep the rest of the system away — that is what isolcpus, housekeeping CPUs, and exclusive cpuset partitions are for. Affinity is one half of isolation, never the whole.
Alternatives and When to Choose Them
sched_setaffinity/taskset— per-task pinning, the lightest tool. Use for ad-hoc pinning of a few threads. Does not stop others from using the CPU.- cpusets — confine a whole cgroup of tasks (and their memory nodes) to a CPU set, and with v2 partitions carve exclusive CPUs. Use when you want a durable, hierarchical, inheritable boundary rather than per-task masks.
- isolcpus= boot parameter — remove CPUs from the general scheduler’s load-balancing pool at boot. Use for the most demanding low-jitter workloads, in combination with affinity to place your task on the cleared CPUs.
These compose: a common production pattern is isolcpus to clear the cores, an exclusive cpuset partition to own them, and sched_setaffinity to place the specific threads.
Production Notes
High-frequency-trading, telecom dataplane (DPDK), and real-time control workloads routinely pin polling threads to dedicated cores so the thread never migrates and its cache and TLB stay warm. The standard recipe is not affinity alone — it is affinity layered on isolated CPUs so nothing else competes. The cpus_ptr indirection matters here: under PREEMPT_RT, a thread holding a per-CPU resource uses migrate-disable to stay put without disabling preemption, which is why the pointer is the read path and not the embedded mask. A frequent operational gotcha is forgetting that container runtimes set cpusets: a thread that “ignores” its sched_setaffinity is usually being clamped by the cgroup cpuset its container lives in.
See Also
- Cpusets and CPU Partitioning — the cgroup controller that supplies
cpuset_cpus_allowed()and clamps the effective mask - CPU Isolation isolcpus and nohz_full — the boot-time way to clear CPUs of other work; affinity places tasks onto them
- Scheduler Load Balancing — obeys
cpus_ptrwhen migrating tasks - Wakeup Balancing and select_task_rq — where affinity gates wakeup placement
- Housekeeping CPUs and Tickless Isolation — where displaced work goes
- The task_struct Process Descriptor — home of
cpus_mask/cpus_ptr/user_cpus_ptr - Migration the Stop Class and the Migration Thread —
PF_NO_SETAFFINITY, the migration kthread - SCHED_DEADLINE and Earliest Deadline First — why deadline tasks resist affinity restriction
- Linux Process Scheduling MOC — parent map (§F Affinity, Isolation, and Partitioning)