CPU Affinity in Linux: Bind Threads to CPU Cores the Right Way
Understand the CPU affinity mask, use sched_setaffinity() and taskset, and learn modern CPU isolation with cgroup v2 cpusets — part of our free Linux kernel development course.
Beginner → Intermediate
Modern 6.x series
Linux CPU Scheduler
CPU affinity in Linux decides which CPU cores a thread is allowed to run on. Every thread in the system carries a per-thread bitmask — the CPU affinity mask — and the scheduler will only ever place that thread on a core whose bit is set in the mask. By default every bit is set, so the scheduler is free to pick any core. But when you care about cache locality, predictable latency, or dedicating a core to one critical task, controlling CPU affinity in Linux becomes a genuinely useful tool.
In this lecture of our free Linux kernel development course, you will learn how the affinity mask works inside the kernel, how to query and change it from user space with the sched_getaffinity() and sched_setaffinity() system calls, how to do the same from the shell with taskset, and how modern kernels expect you to isolate CPUs using cgroup v2 cpusets instead of the older isolcpus boot parameter.
What You Will Learn
- What the CPU affinity mask is and where it lives inside the kernel’s task structure
- How per-CPU runqueues and the load balancer interact with CPU affinity in Linux
- How to query and set affinity with
sched_getaffinity(),sched_setaffinity()and thecpu_set_thelper macros - How to pin threads (not just processes) using
pthread_setaffinity_np() - Command-line control with
tasksetand how to inspect affinity via/proc - Modern CPU isolation: why
isolcpusis considered legacy and how cgroup v2 cpuset partitions replace it - Best practices, common mistakes, and performance trade-offs
Prerequisites
- Comfort with C programming and compiling with
gcc - Basic familiarity with Linux processes and threads (a thread is the schedulable unit on Linux)
- Earlier lectures of this series on scheduler classes and runqueues are helpful but not mandatory
What Is the CPU Affinity Mask in Linux?
Inside the kernel, every thread is represented by a task structure that stores dozens of scheduling attributes: its priority, its scheduling class, the runqueue it currently belongs to, and — the star of this lecture — its CPU affinity mask. The mask is simply an array of bits, one bit per logical CPU. If the bit for a core is 1, the thread may be scheduled on that core; if it is 0, the scheduler will never place the thread there.
A small but important modernization note: older kernels stored this mask in a field named cpus_allowed. Since kernel 5.3, the task structure instead carries cpus_mask (the actual bitmask) together with a pointer named cpus_ptr that the scheduler reads. This indirection lets the kernel temporarily restrict a thread to one CPU (for example during certain hotplug or migration operations) without losing the thread’s original mask. If you read older books or articles that mention cpus_allowed, mentally map it to cpus_mask / cpus_ptr on any recent kernel.
Because the mask is per-thread, two threads of the same process can have completely different affinity settings. That fits the Linux model perfectly: the kernel schedules threads, not processes.
| Bit position | CPU 3 | CPU 2 | CPU 1 | CPU 0 |
| Default mask (0xF) | 1 | 1 | 1 | 1 |
| Mask 0xA (cores 1 & 3 only) | 1 | 0 | 1 | 0 |
Green cells = thread allowed on this core | Red cells = core forbidden for this thread
With four logical CPUs, the default mask is binary 1111, which is 0xF in hexadecimal — the thread can run anywhere. Change the mask to binary 1010 (0xA) and the thread is now allowed only on cores 1 and 3 (core numbering starts at zero).
How CPU Affinity in Linux Interacts with Runqueues and Load Balancing
Every logical CPU on a modern Linux system owns its own runqueue. A runnable thread sits on exactly one runqueue at a time, and by default it executes on the CPU that owns that runqueue. Since kernel 6.6, the fair scheduling class picks the next task using the EEVDF (Earliest Eligible Virtual Deadline First) algorithm, which replaced the long-serving CFS design — but the runqueue-per-CPU model and the affinity rules described here are unchanged.
Queues naturally become unbalanced: one core ends up with five runnable threads while another sits idle. The scheduler’s load balancer periodically migrates threads between runqueues to even things out, assisted by per-CPU kernel threads named migration/N (where N is the core number). Here is the key rule: the load balancer always respects the affinity mask. A thread pinned to cores 1 and 3 will never be migrated to core 0 or 2, no matter how idle they are.
T1, T4
T2 (pinned 1,3)
idle
T3
This is why the general advice is: let the scheduler do its job. The kernel understands the CPU topology — which logical CPUs share caches, which sit on the same package, which are SMT siblings — far better than a hand-written pinning scheme usually does. Manipulate CPU affinity in Linux only when you have a measured reason to.
When Setting the Affinity Mask Actually Helps
- Cache warmth: keeping a thread on one core avoids repeatedly refilling L1/L2 caches after migration (“cache bouncing”).
- Zero migration cost: a pinned thread never pays the price of being moved between runqueues.
- CPU reservation: dedicating one or more cores exclusively to a critical thread — a technique common in real-time and low-latency systems (packet processing, audio pipelines, trading systems, motor control).
Querying and Setting CPU Affinity in Linux with System Calls
User space controls the mask through two system calls, declared in <sched.h> (you must define _GNU_SOURCE before including it):
#define _GNU_SOURCE
#include <sched.h>
int sched_getaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *mask);
int sched_setaffinity(pid_t pid, size_t cpusetsize, const cpu_set_t *mask);
Passing pid = 0 means “the calling thread”. The mask is expressed through an opaque type, cpu_set_t, which you must never poke directly — glibc gives you a family of helper macros instead:
| Macro | Purpose |
CPU_ZERO(&set) |
Clear the set — always call this first |
CPU_SET(n, &set) |
Allow CPU n |
CPU_CLR(n, &set) |
Forbid CPU n |
CPU_ISSET(n, &set) |
Test whether CPU n is allowed |
CPU_COUNT(&set) |
Number of CPUs currently allowed |
Complete Example: Query, Then Pin the Current Process
The following original demo program first prints the caller’s current affinity, then restricts itself to CPUs 1 and 3, and finally verifies the change. Save it as affinity_demo.c:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
static void show_affinity(const char *when)
{
cpu_set_t set;
long nproc = sysconf(_SC_NPROCESSORS_ONLN);
CPU_ZERO(&set);
if (sched_getaffinity(0, sizeof(set), &set) == -1) {
perror("sched_getaffinity");
exit(EXIT_FAILURE);
}
printf("%s: allowed on %d CPU(s):", when, CPU_COUNT(&set));
for (long cpu = 0; cpu < nproc; cpu++)
if (CPU_ISSET(cpu, &set))
printf(" %ld", cpu);
printf("\n");
}
int main(void)
{
cpu_set_t newset;
show_affinity("Before");
/* Allow only CPUs 1 and 3 (mask 0xA on a 4-core box) */
CPU_ZERO(&newset);
CPU_SET(1, &newset);
CPU_SET(3, &newset);
if (sched_setaffinity(0, sizeof(newset), &newset) == -1) {
perror("sched_setaffinity");
exit(EXIT_FAILURE);
}
show_affinity("After");
printf("Now running on CPU %d\n", sched_getcpu());
return 0;
}
Build and run it:
gcc -Wall -O2 affinity_demo.c -o affinity_demo
./affinity_demo
Typical output on a 4-core machine:
Before: allowed on 4 CPU(s): 0 1 2 3
After: allowed on 2 CPU(s): 1 3
Now running on CPU 3
Two practical notes. First, cpu_set_t statically supports up to CPU_SETSIZE (1024) CPUs; on huge servers with more logical CPUs, use the dynamic CPU_ALLOC() family. Second, changing the affinity of a different process requires either the same UID or the CAP_SYS_NICE capability — otherwise the call fails with EPERM.
Pinning Individual Threads with pthreads
Because affinity is per-thread, POSIX threads get their own wrappers:
#define _GNU_SOURCE
#include <pthread.h>
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(2, &set); /* pin this worker to CPU 2 */
int ret = pthread_setaffinity_np(pthread_self(), sizeof(set), &set);
if (ret != 0)
fprintf(stderr, "pthread_setaffinity_np failed: %d\n", ret);
The _np suffix means “non-portable” — these calls are Linux-specific, which is exactly what we want in a Linux kernel programming course.
CPU_ZERO()start clean
CPU_SET(n)choose cores
sched_setaffinity()apply mask
sched_getcpu() / /procControlling CPU Affinity in Linux from the Command Line
You do not always need C code. The taskset utility (from util-linux) reads and writes the mask of any process:
# Show the affinity of PID 1234 (hex mask and CPU list)
taskset -p 1234
taskset -cp 1234
# Restrict an existing process to CPUs 1 and 3
taskset -cp 1,3 1234
# Launch a new program already pinned to CPUs 0-2
taskset -c 0-2 ./my_benchmark
You can also simply read a thread’s mask from procfs — no tools required:
grep Cpus_allowed /proc/1234/status
Cpus_allowed: a
Cpus_allowed_list: 1,3
CPU Isolation on Modern Kernels: isolcpus vs cgroup v2 cpusets
Pinning your thread to a core is only half of CPU reservation — you also need to keep every other thread off that core. Doing that by hand (resetting the affinity of every task at every creation) is impractical, so the kernel offers system-level mechanisms.
The classic approach is the isolcpus= kernel boot parameter, which removes the listed CPUs from general scheduling and load balancing at boot; afterwards, only tasks explicitly affined to those CPUs will run there. It still works on today’s kernels, but the kernel documentation itself marks it as deprecated, mainly because it is static: you cannot change the isolated set without a reboot.
The modern, dynamic replacement is the cgroup v2 cpuset controller. You create a cgroup, grant it a set of CPUs, and mark it as an isolated partition — the kernel then removes those CPUs from load balancing at runtime, and you can undo it just as easily:
# cgroup v2 example (run as root)
mkdir /sys/fs/cgroup/rtapp
echo "+cpuset" > /sys/fs/cgroup/cgroup.subtree_control
# Give the group CPU 3 and make it an isolated partition
echo 3 > /sys/fs/cgroup/rtapp/cpuset.cpus
echo isolated > /sys/fs/cgroup/rtapp/cpuset.cpus.partition
# Move your critical task into the group
echo <PID> > /sys/fs/cgroup/rtapp/cgroup.procs
For hard real-time or extreme low-latency work, teams often combine this with nohz_full= (to stop the periodic scheduler tick on the isolated cores) and IRQ affinity tuning so that hardware interrupts are steered away from the reserved CPUs.
| Aspect | isolcpus= (boot param) | cgroup v2 cpuset partition |
| When applied | Boot time only | Any time, at runtime |
| Reversible without reboot | No | Yes |
| Status in kernel docs | Deprecated (still functional) | Recommended |
| Granularity | Whole system | Per-cgroup hierarchy |
Common Mistakes and Troubleshooting
- Forgetting
_GNU_SOURCE: without it,cpu_set_tand the affinity APIs are not declared and compilation fails. - Skipping
CPU_ZERO(): an uninitializedcpu_set_tcontains garbage bits, so your thread may silently be allowed on random cores. EINVALfromsched_setaffinity(): the mask contains no online CPU, or every CPU in it is invalid on this system.EPERM: you tried to change another user’s process withoutCAP_SYS_NICE.- Pinning everything: over-pinning defeats the load balancer and usually reduces total throughput. Pin only the threads that measurably benefit.
- Ignoring SMT siblings: two hyper-threads on one physical core share execution resources; pinning two heavy threads to sibling logical CPUs can hurt more than help. Check the topology with
lscpu -e.
Best Practices and Performance Considerations
- Trust the scheduler first; it knows the cache and package topology. Reach for affinity only after profiling shows migration or cache misses are a real cost.
- Verify results, don’t assume: use
sched_getcpu(),taskset -cp, orperf schedto confirm where threads actually run. - For latency-critical work, combine thread pinning with cgroup v2 isolated partitions and IRQ steering — pinning alone does not stop other tasks or interrupts from using the core.
- Make core numbers configurable in your application; hard-coded CPU IDs break the moment the software moves to different hardware.
- On embedded multi-core SoCs (common in automotive and telecom middleware), affinity is often used to separate a real-time data path from housekeeping threads — the same APIs shown above apply unchanged.
FAQ: CPU Affinity in Linux
Is CPU affinity a process property or a thread property?
It is per-thread. Each thread carries its own mask in the kernel task structure, so different threads of one process can be pinned to different cores.
What is the default CPU affinity mask?
All bits set — on a 4-core system that is binary 1111 (0xF), meaning the thread may run on any online CPU.
Does the load balancer ever violate the affinity mask?
No. Load balancing and the migration/N kernel threads only move a thread among the CPUs permitted by its mask.
Is affinity inherited across fork() and exec()?
Yes. A child created with fork() inherits the parent’s mask, and it is preserved across execve(). This is exactly how taskset -c 0-2 ./program works.
Does setting affinity guarantee my thread gets the CPU?
No. It only restricts where the thread may run; other threads can still compete for the same core. For exclusivity you additionally need CPU isolation (cgroup v2 cpuset partitions or isolcpus).
Is isolcpus still usable on kernel 6.x?
Yes, it still works, but the kernel documentation labels it deprecated in favor of the runtime-configurable cgroup v2 cpuset mechanism.
How can I check which CPU my code is running on right now?
Call sched_getcpu() from C, or read /proc/<PID>/stat field 39, or watch it live with htop (enable the PROCESSOR column).
Did the EEVDF scheduler change how CPU affinity works?
No. EEVDF (kernel 6.6+) changed how the fair class picks the next task on a runqueue; the affinity mask, runqueue model, and all APIs in this lecture behave exactly the same.
Key Takeaways
- The CPU affinity mask is a per-thread bitmask (one bit per logical CPU) stored in the kernel task structure —
cpus_mask/cpus_ptron modern kernels. - Default = all cores allowed; the scheduler plus load balancer place threads freely within the mask.
- Query and set the mask with
sched_getaffinity()/sched_setaffinity(), per-thread withpthread_setaffinity_np(), or from the shell withtaskset. - Always
CPU_ZERO()before building a mask, and remember the permission rules (CAP_SYS_NICE). - True CPU reservation needs isolation: prefer cgroup v2 cpuset isolated partitions over the legacy
isolcpusboot parameter. - Use affinity sparingly and measure — the scheduler’s topology awareness is hard to beat.
Conclusion
CPU affinity in Linux is a small mechanism with a big impact: one bitmask per thread quietly constrains everything the scheduler and load balancer are allowed to do. You have now seen the concept, the kernel-side picture (per-CPU runqueues, migration threads, the modern EEVDF fair class), the full user-space toolkit (sched_setaffinity(), pthread wrappers, taskset, procfs), and the current best practice for CPU isolation using cgroup v2 cpusets. In the next lecture of this free Linux kernel development course we continue our journey through the Linux CPU scheduler — keep practicing the demo program on your own machine and experiment with taskset on real workloads.
sched_setaffinity
cpu_set_t
taskset
CPU isolation
cgroup v2 cpuset
Linux scheduler
free Linux kernel development course

1 Comment