Pinned to CPUs 0-7 but running only on 2-3: how Linux cpusets narrow your affinity mask
The kernel silently intersects your sched_setaffinity mask with the CPUs your cpuset/cgroup allows and the CPUs that exist. How to see the real set and diagnose EINVAL, per the man pages.
You pass CPUs 0 through 7 to sched_setaffinity() and it returns 0. Then that thread’s /proc/<TID>/status shows Cpus_allowed_list: 2-3. The call didn’t fail and the kernel isn’t broken. The mask you asked for is not applied as is: the kernel keeps only the part that overlaps with what the thread is allowed to use, and it doesn’t tell you.
This post covers where that intersection comes from, how to see which CPUs are actually allowed, and what to check first when the intersection is empty and you get EINVAL. It is based on sched_setaffinity(2), cpuset(7), proc_pid_status(5) and the cgroup v2 documentation.
The mask you request is not the set you run on
The NOTES section of sched_setaffinity(2) says that after the call, the set of CPUs the thread will actually run on is the intersection of the set in the mask argument and the set of CPUs actually present on the system. If the cpuset mechanism is in use, the system may restrict it further. The kernel imposes these restrictions silently.
cpuset(7) says the same thing from the other side. Cpusets are integrated with sched_setaffinity(2), and affinity cannot give a process a CPU its cpuset doesn’t allow. When the two conflict, cpuset placement wins, and the kernel silently restricts the requested CPUs to those the cpuset allows.
So there are three layers:
| Layer | What it is | Where to look |
|---|---|---|
| Requested | The mask you pass to sched_setaffinity() |
Your code |
| Allowed | The CPUs the thread’s cpuset allows | cgroup v2: cpuset.cpus.effective / v1: the cpuset’s cpuset.cpus |
| Present | The CPUs that exist on the system | /sys/devices/system/cpu/, sysconf(_SC_NPROCESSORS_ONLN), etc. |
The thread can run only where all three overlap. If the cpuset in the opening example allows 2-3, asking for 0-7 leaves you with 2-3.
When it succeeds and when it’s EINVAL
If part of the intersection survives, the call succeeds. It only turns into an error when the intersection is completely empty. The ERRORS section of sched_setaffinity(2) describes EINVAL as the case where the mask contains no processors that are currently physically on the system and permitted to the thread by cpuset cgroups or the cpuset(7) mechanism. cpuset(7) likewise notes that a call can return an error if restricting the request to the cpuset leaves an empty set.
With the same cpuset as above (2-3 allowed), you would get the following. This is an illustration that applies the documented rules, not measured output.
| Request | Result |
|---|---|
| 0-7 | Succeeds; actually runs on 2-3 only |
| 3 | Succeeds; runs on 3 only |
| 0-1 | EINVAL |
The other errors from sched_setaffinity() have different causes, so keep them apart:
EPERM: not enough privilege. The caller needs an effective UID equal to the real or effective UID of the target thread, orCAP_SYS_NICEin the target thread’s user namespace.ESRCH: no thread with thatpid.EFAULT: a bad memory address.
EINVAL has one more cause. sched_getaffinity() returns it when cpusetsize is smaller than the affinity mask the kernel uses (before Linux 2.6.9 this applied to sched_setaffinity() too). glibc’s cpu_set_t is a fixed 128 bytes, so it can represent CPU numbers up to 1023. If the kernel mask is larger, sched_getaffinity() with sizeof(cpu_set_t) fails with EINVAL. The man page says to allocate the mask dynamically with CPU_ALLOC(3) and probe with increasing sizes until the call stops failing. That has nothing to do with cpusets, so when you see EINVAL, start by checking which call returned it.
Affinity is per thread
The affinity mask belongs to a thread, not a process. Per sched_setaffinity(2), each thread in a thread group can have its own. Passing a value from gettid(2) as pid targets that thread, 0 targets the calling thread, and a value from getpid(2) targets the main thread of the thread group. With the POSIX threads API, the page says to use pthread_setaffinity_np(3) instead. A child created by fork(2) inherits its parent’s mask, and the mask is preserved across execve(2).
Cpusets work the same way. The NOTES section of cpuset(7) says that despite its name, the pid parameter is actually a thread ID, and each thread in a thread group can be attached to a different cpuset.
This matters when diagnosing. If you look only at the main thread and conclude that affinity wasn’t applied, you can miss the worker thread whose mask actually changed.
Checking the real allowed set
1. /proc/<TID>/status
grep Cpus_allowed /proc/<TID>/status
proc_pid_status(5) describes Cpus_allowed as a hexadecimal mask of the CPUs on which this process may run, and Cpus_allowed_list as the same thing in list format. cpuset(7) describes these lines as the CPUs the process may be scheduled on. Cpus_allowed_list is easier to read: 0-4,9 means CPUs 0, 1, 2, 3, 4 and 9.
The mask format of Cpus_allowed writes each 32-bit word in hexadecimal, separates words with commas, and puts the most significant bit first. 00000001 is CPU 0 alone; 000000ff,00000000 is CPUs 32-39.
In this file, per proc_pid_status(5), the Pid field is a thread ID. If you put a process ID in the command above, you see the thread with that ID, which is the main thread. When you are diagnosing a worker thread, use that worker’s TID (the value gettid(2) returns in that thread) instead.
2. cgroup v2: cpuset.cpus vs cpuset.cpus.effective
First find which cgroup the target thread is in. Use its TID here too; a process ID shows the main thread’s cgroup, which can differ from a worker’s.
cat /proc/<TID>/cgroup
The cgroup v2 entry is always in the form 0::$PATH. Append that path to wherever cgroup2 is mounted and you have the cgroup’s directory. The mount point varies by system, so read $MOUNT_POINT below as your own.
cat "$MOUNT_POINT$PATH/cpuset.cpus"
cat "$MOUNT_POINT$PATH/cpuset.cpus.effective"
The difference between these two files is the heart of the diagnosis.
cpuset.cpuslists the CPUs this cgroup requested. What it is actually granted is subject to its parent’s constraints and can differ. If it is empty, the cgroup uses the setting of the nearest ancestor with a non-empty value, or all available CPUs if there is none. CPU hotplug does not change it.cpuset.cpus.effectivelists the online CPUs actually granted to this cgroup by its parent, which tasks in the cgroup may use. Hotplug events change it. If none of the CPUs incpuset.cpuscan be granted, it is treated as ifcpuset.cpuswere empty.
That’s how you end up asking “I allowed 0-7, so why 2-3?” while looking at cpuset.cpus. The real allowed set is in cpuset.cpus.effective. The cpuset controller is hierarchical, so a cgroup cannot use CPUs its parent doesn’t allow. If the value isn’t what you expected, walk up the path and check each parent’s cpuset.cpus.effective.
Inside a container there is one more catch. For a process in a cgroup namespace, /proc/$PID/cgroup shows the path relative to the namespace root. In the documentation’s example, a path that reads 0::/batchjobs/container_id1 from the host reads 0::/ inside the namespace. The same process can show different paths depending on where you look from.
3. Legacy cpuset filesystem (cgroup v1)
On a system using the older interface described in cpuset(7), find the path with the target thread’s TID:
cat /proc/<TID>/cpuset
The output is the path relative to the root of the cpuset filesystem, and that directory’s cpuset.cpus is the list of allowed CPUs. The page gives /dev/cpuset as the common mount point. On kernels built with cpuset support every process is attached to some cpuset, but if the configuration hasn’t been changed or the filesystem isn’t mounted, the mechanism has no effect.
4. CPUs present on the system
sched_setaffinity(2) lists ways to determine the number of CPUs: reading /proc/cpuinfo, using sysconf(3) for _SC_NPROCESSORS_CONF and _SC_NPROCESSORS_ONLN, and listing the CPU directories under /sys/devices/system/cpu/. A CPU number that doesn’t exist on the system drops out of the intersection regardless of any cpuset.
A small program to check it yourself
It asks for 0-7, then prints its own Cpus_allowed_list. The program has a single thread, so pid 0 (the calling thread) and /proc/self/status (the main thread) refer to the same thread.
#define _GNU_SOURCE
#include <errno.h>
#include <sched.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
cpu_set_t want;
CPU_ZERO(&want);
for (int cpu = 0; cpu < 8; cpu++)
CPU_SET(cpu, &want);
if (sched_setaffinity(0, sizeof(want), &want) == -1) {
/* EINVAL: none of 0-7 is both present and allowed */
fprintf(stderr, "sched_setaffinity: %s\n", strerror(errno));
return 1;
}
FILE *f = fopen("/proc/self/status", "r");
if (f == NULL) {
perror("fopen");
return 1;
}
char line[4096];
while (fgets(line, sizeof(line), f) != NULL)
if (strncmp(line, "Cpus_allowed_list:", 18) == 0)
fputs(line, stdout);
fclose(f);
return 0;
}
If the printed list is narrower than 0-7, the missing CPUs were cut by the cpuset or because they don’t exist. Run the same program in a different cgroup and the result can change.
Order of diagnosis
- Which call failed? If it’s
EINVALfromsched_getaffinity(), suspect the mask size (cpusetsize) before the cpuset. - Do the requested CPU numbers exist? Check
/sys/devices/system/cpu/or similar. - Which cgroup is the thread in? Use the target thread’s TID, not the process ID:
/proc/<TID>/cgroup(or/proc/<TID>/cpuseton v1). - What is that cgroup’s
cpuset.cpus.effective? No overlap with the request meansEINVAL; partial overlap means it was silently trimmed. - Confirm the result in
Cpus_allowed_list. Remember it can differ per thread.
Hotplug and isolcpus
The EXCEPTIONS section of cpuset(7) has an exception in the other direction. If hotplug removes all the CPUs assigned to a cpuset, the kernel updates cpus_allowed of the affected processes to allow all CPUs. The kernel prefers to violate cpuset placement rather than starve a process whose allowed CPUs have all gone offline. The page recommends that user code reconfigure cpusets to refer only to online CPUs when using hotplug to add or remove them. cpuset(7) documents the legacy cpuset interface, so don’t carry this exception over to cgroup v2 as is. The v2 documentation only says that cpuset.cpus.effective is affected by hotplug.
CPUs isolated with the isolcpus boot option don’t get processes scheduled on them, and per sched_setaffinity(2), the only ways to put processes there are sched_setaffinity() or the cpuset mechanism. Per the cgroup v2 documentation, CPUs isolated with isolcpus can only be put into a partition if it is an isolated partition.
Measure before talking about performance
sched_setaffinity(2) says affinity can be used to obtain performance benefits, and gives avoiding the cache invalidation cost of moving between CPUs as an example. Its example program shows that run times for two processes on the same CPU, on different CPUs of the same core, and on different cores depend on caching effects and placement. None of that guarantees your workload gets faster, and this post doesn’t offer performance numbers either.
Before changing affinity or cpusets on a production service, check what is actually allowed right now, and after the change measure what differs under the same conditions. Widening or narrowing a cpuset also affects every other process in that cgroup.
Scope and limits
- This post is based on the man page sources in the Linux man-pages git repository (unreleased version) and the cgroup v2 documentation on docs.kernel.org (7.3.0-rc7 build), checked on 2026-10-18. Man pages installed by your distribution and your kernel version may differ.
- Versions stated in the sources:
Cpus_allowedsince Linux 2.6.24,Cpus_allowed_listsince 2.6.26, cpusets since 2.6.12,sched_setaffinitysince Linux 2.5.8 and glibc 2.3. - cpuset(7) describes the legacy
/dev/cpusetinterface. cgroup v2 file names and behavior follow the cgroup v2 documentation. - Command-line tools such as
tasksetare not covered because their behavior isn’t in the sources checked for this post.
Sources
Checked on 2026-10-18.