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 the sched_setaffinity(2) system call (or the taskset(1) wrapper) and reads it back with sched_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 in user_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’s user_cpus_ptr introduction (commit b90ca8badbd1, 2021) and Waiman Long’s “Always preserve the user requested cpumask” (commit 8f9ea86fdf99, 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_mask is the effective affinity — the actual bitmap of CPUs the task may run on right now, after every constraint has been applied. It is a cpumask_t embedded directly in task_struct (not a pointer), so it always exists.
  • cpus_ptr is a const cpumask_t * that the scheduler reads through whenever it needs the allowed set. In the normal case cpus_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 clobbering cpus_mask. This is the load-bearing detail: scheduler hot paths dereference cpus_ptr, not cpus_mask, precisely so migrate-disable can swap the pointer cheaply and restore it afterward.
  • user_cpus_ptr is NULL until userspace calls sched_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 recompute cpus_mask if the cpuset boundary moves.
  • nr_cpus_allowed is the cached population count of cpus_mask. The scheduler checks nr_cpus_allowed > 1 as 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_maskthis 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_* or sched_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’s uapi/linux/sched.h flag 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: grep SCHED_FLAG_ in include/uapi/linux/sched.h at the v6.18 tag. uncertain

Failure Modes and Common Misunderstandings

  • EINVAL from 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 and sched_setaffinity returns EINVAL (“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 and sched_getaffinity reports {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 in user_cpus_ptr.
  • EPERM setting another user’s task. Changing affinity on a task you do not own requires CAP_SYS_NICE; without it you get EPERM.
  • EINVAL on >1024-CPU machines with static cpu_set_t. The glibc fixed mask cannot express CPU numbers above 1023; use CPU_ALLOC.
  • Pinning a kernel per-CPU thread fails. Threads with PF_NO_SETAFFINITY (per-CPU kthreads, the migration thread) refuse affinity changes with EINVAL.
  • 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