CPU affinity를 0-7로 걸었는데 2-3에서만 돈다: 리눅스 cpuset이 마스크를 줄이는 방식

sched_setaffinity로 넘긴 CPU 마스크는 cpuset/cgroup이 허용한 CPU, 실제로 있는 CPU와의 교집합으로 조용히 줄어듭니다. 실제 허용 범위를 확인하는 방법과 EINVAL을 진단하는 순서를 man 페이지 기준으로 정리합니다.

linuxcpu-affinitycgroupscheduler

sched_setaffinity()에 CPU 0부터 7까지 넣었고 호출은 0을 돌려줬습니다. 그런데 그 스레드의 /proc/<TID>/status를 열어 보면 Cpus_allowed_list에 2-3만 있습니다. 호출이 실패한 것도 아니고 커널 버그도 아닙니다. 요청한 마스크는 그대로 적용되지 않고, 커널이 허용 범위와 겹치는 부분만 남깁니다. 그리고 그 사실을 따로 알려 주지 않습니다.

이 글은 그 교집합이 어디서 생기는지, 실제로 허용된 CPU를 어떻게 확인하는지, 교집합이 비어서 EINVAL이 날 때 무엇부터 보면 되는지를 sched_setaffinity(2), cpuset(7), proc_pid_status(5), cgroup v2 문서 기준으로 정리합니다.

요청한 마스크와 실제로 돌 수 있는 CPU는 다르다

sched_setaffinity(2)의 NOTES 절은 이렇게 적습니다. 호출 뒤 스레드가 실제로 실행되는 CPU 집합은 mask 인수로 지정한 집합과 시스템에 실제로 있는 CPU 집합의 교집합이고, cpuset 메커니즘을 쓰고 있다면 시스템이 그 범위를 더 좁힐 수 있습니다. 이 제한은 커널이 조용히(silently) 적용합니다.

cpuset(7)도 같은 이야기를 반대쪽에서 합니다. cpuset은 sched_setaffinity(2)와 통합되어 있어서, affinity로는 프로세스의 cpuset이 허용하지 않는 CPU를 쓸 수 없습니다. 둘이 충돌하면 cpuset 배치가 우선하고, 커널은 요청된 CPU를 그 cpuset이 허용하는 범위로 조용히 줄입니다.

정리하면 세 겹입니다.

층 무엇인가 어디서 보나
요청 sched_setaffinity()에 넘긴 mask 호출하는 코드
허용 그 스레드가 속한 cpuset이 허용하는 CPU cgroup v2: cpuset.cpus.effective / v1: cpuset의 cpuset.cpus
존재 시스템에 실제로 있는 CPU /sys/devices/system/cpu/, sysconf(_SC_NPROCESSORS_ONLN) 등

실제로 돌 수 있는 CPU는 이 셋이 모두 겹치는 부분입니다. 앞의 예에서 cpuset이 2-3만 허용했다면, 0-7을 요청해도 남는 것은 2-3입니다.

언제 성공하고 언제 EINVAL인가

교집합이 일부만 남으면 호출은 성공합니다. 오류로 바뀌는 것은 교집합이 완전히 비었을 때입니다. sched_setaffinity(2)의 ERRORS 절은 EINVAL을 “마스크에 현재 시스템에 물리적으로 존재하면서 cpuset cgroup이나 cpuset(7) 메커니즘이 그 스레드에 허용한 프로세서가 하나도 없을 때”로 설명합니다. cpuset(7)도 cpuset 범위로 줄인 결과가 빈 집합이 되면 호출이 오류를 낼 수 있다고 적습니다.

그래서 앞의 예와 같은 cpuset(허용 2-3)이라면 이렇게 됩니다. 아래는 문서의 규칙을 적용한 설명용 예이고, 실제로 측정한 출력이 아닙니다.

요청 결과
0-7 성공. 실제로는 2-3에서만 실행
3 성공. 3에서만 실행
0-1 EINVAL

sched_setaffinity()가 낼 수 있는 다른 오류도 원인이 다르니 구분해 둡니다.

  • EPERM: 권한 부족. 호출자의 effective UID가 대상 스레드의 real 또는 effective UID와 같거나, 대상 스레드의 user namespace에서 CAP_SYS_NICE를 가져야 합니다.
  • ESRCH: pid에 해당하는 스레드가 없음.
  • EFAULT: 잘못된 메모리 주소.

EINVAL에는 다른 원인도 하나 있습니다. sched_getaffinity()에서 cpusetsize가 커널이 쓰는 affinity 마스크 크기보다 작을 때입니다(Linux 2.6.9 이전에는 sched_setaffinity()도 해당). glibc의 cpu_set_t는 128바이트 고정이라 CPU 번호 1023까지만 표현할 수 있고, 커널 마스크가 그보다 크면 sizeof(cpu_set_t)를 넘긴 sched_getaffinity()가 EINVAL로 실패합니다. man 페이지는 이때 CPU_ALLOC(3)으로 마스크를 동적으로 잡고, 실패하지 않을 때까지 크기를 늘려 가며 확인하라고 안내합니다. cpuset과는 무관한 원인이니, EINVAL이 났다면 어느 호출에서 났는지부터 보세요.

affinity는 스레드 단위다

affinity 마스크는 프로세스가 아니라 스레드의 속성입니다. sched_setaffinity(2)에 따르면 스레드 그룹 안의 각 스레드가 따로 가질 수 있고, pid에 gettid(2)의 값을 넘기면 그 스레드에, 0을 넘기면 호출한 스레드에, getpid(2)의 값을 넘기면 스레드 그룹의 메인 스레드에 적용됩니다. POSIX 스레드 API를 쓴다면 pthread_setaffinity_np(3)를 쓰라고 적혀 있습니다. fork(2)로 만든 자식은 부모의 마스크를 물려받고, execve(2)를 거쳐도 마스크는 유지됩니다.

cpuset 쪽도 비슷합니다. cpuset(7)의 NOTES는 이름과 달리 pid 인수가 실제로는 스레드 ID이고, 스레드 그룹의 각 스레드가 서로 다른 cpuset에 붙을 수 있다고 적습니다.

진단할 때 이 점이 중요합니다. 메인 스레드만 보고 “affinity가 안 걸렸다”고 판단하면, 실제로 마스크를 바꾼 작업 스레드를 놓칠 수 있습니다.

실제 허용 범위 확인하기

1. /proc/<TID>/status

grep Cpus_allowed /proc/<TID>/status

proc_pid_status(5)는 Cpus_allowed를 “이 프로세스가 실행될 수 있는 CPU의 16진수 마스크”, Cpus_allowed_list를 같은 내용의 목록 형식으로 설명합니다. cpuset(7)도 이 줄을 “어느 CPU에 스케줄될 수 있는지”로 설명합니다. 사람이 읽기에는 Cpus_allowed_list가 편합니다. 0-4,9는 CPU 0, 1, 2, 3, 4, 9라는 뜻입니다.

Cpus_allowed의 마스크 형식은 32비트 워드마다 16진수로 쓰고, 워드가 여럿이면 쉼표로 나누며, 가장 높은 비트가 먼저 나옵니다. 예를 들어 00000001은 CPU 0만, 000000ff,00000000은 CPU 32-39를 뜻합니다.

proc_pid_status(5)에서 이 파일의 Pid 필드는 스레드 ID입니다. 위 명령에 프로세스 ID를 넣으면 그 ID를 가진 스레드, 즉 메인 스레드를 보게 됩니다. 작업 스레드를 진단한다면 그 스레드의 TID(그 스레드에서 gettid(2)가 돌려주는 값)를 넣으세요.

2. cgroup v2: cpuset.cpus와 cpuset.cpus.effective

먼저 대상 스레드가 어느 cgroup에 있는지 봅니다. 여기서도 TID를 씁니다. 프로세스 ID를 넣으면 메인 스레드의 cgroup이 나오는데, 작업 스레드의 cgroup과 다를 수 있습니다.

cat /proc/<TID>/cgroup

cgroup v2 항목은 항상 0::$PATH 형식입니다. 이 경로를 cgroup2가 마운트된 위치 아래에 붙이면 그 cgroup 디렉터리가 됩니다. 마운트 위치는 시스템마다 다르니, 아래 $MOUNT_POINT는 각자 환경에 맞게 바꿔 읽으세요.

cat "$MOUNT_POINT$PATH/cpuset.cpus"
cat "$MOUNT_POINT$PATH/cpuset.cpus.effective"

두 파일의 차이가 진단의 핵심입니다.

  • cpuset.cpus는 이 cgroup이 요청한 CPU입니다. 실제로 받는 CPU는 부모가 거는 제약 때문에 이 값과 다를 수 있습니다. 비어 있으면 가장 가까운 조상 중 값이 있는 cgroup의 설정을 따르고, 그런 조상이 없으면 사용 가능한 모든 CPU를 씁니다. CPU 핫플러그가 일어나도 이 값은 바뀌지 않습니다.
  • cpuset.cpus.effective는 부모가 이 cgroup에 실제로 내준 온라인 CPU이고, cgroup 안의 태스크가 쓸 수 있는 CPU입니다. 핫플러그에 따라 값이 바뀝니다. cpuset.cpus에 적은 CPU를 하나도 받을 수 없으면 cpuset.cpus가 빈 것처럼 취급됩니다.

그러니 cpuset.cpus만 보고 “0-7을 허용했는데 왜 2-3이냐”고 묻게 되는 경우가 있습니다. 실제 허용 범위는 cpuset.cpus.effective 쪽입니다. cpuset 컨트롤러는 계층적이라 부모가 허용하지 않은 CPU는 쓸 수 없습니다. 값이 기대와 다르면 경로를 따라 부모 cgroup의 cpuset.cpus.effective도 차례로 확인하세요.

컨테이너 안에서 볼 때는 주의할 점이 있습니다. cgroup namespace 안의 프로세스에게 /proc/$PID/cgroup은 namespace 루트 기준 경로로 보입니다. 문서의 예에서는 호스트에서 0::/batchjobs/container_id1이던 경로가 namespace 안에서 0::/로 보입니다. 같은 프로세스라도 어디서 보느냐에 따라 경로가 다릅니다.

3. 레거시 cpuset 파일 시스템(cgroup v1)

cpuset(7)이 설명하는 옛 인터페이스를 쓰는 시스템이라면 대상 스레드의 TID로 경로를 이렇게 확인합니다.

cat /proc/<TID>/cpuset

출력은 cpuset 파일 시스템 루트 기준 경로이고, 그 디렉터리의 cpuset.cpus가 허용 CPU 목록입니다. 이 문서는 흔한 마운트 위치로 /dev/cpuset을 듭니다. cpuset 지원이 들어간 커널에서는 모든 프로세스가 어떤 cpuset에 속해 있지만, 설정을 바꾸지 않았거나 파일 시스템을 마운트하지 않았다면 동작에 영향을 주지 않습니다.

4. 시스템에 있는 CPU

sched_setaffinity(2)는 CPU 수를 알아내는 방법으로 /proc/cpuinfo 확인, sysconf(3)의 _SC_NPROCESSORS_CONF와 _SC_NPROCESSORS_ONLN, /sys/devices/system/cpu/ 아래 CPU 디렉터리 목록을 듭니다. 시스템에 없는 CPU 번호는 cpuset 설정과 상관없이 교집합에서 빠집니다.

직접 확인하는 작은 프로그램

0-7을 요청한 뒤, 자기 자신의 Cpus_allowed_list를 출력합니다. 스레드가 하나뿐인 프로그램이라 pid 0(호출한 스레드)과 /proc/self/status(메인 스레드)가 같은 스레드를 가리킵니다.

#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이면 0-7 중 존재하면서 허용된 CPU가 하나도 없다는 뜻 */
        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;
}

출력된 목록이 0-7보다 좁다면 그만큼이 cpuset이나 CPU 존재 여부 때문에 잘려 나간 부분입니다. 같은 프로그램을 다른 cgroup에서 돌리면 결과가 달라질 수 있습니다.

진단 순서

  1. 어느 호출이 실패했나. sched_getaffinity()의 EINVAL이라면 cpuset보다 마스크 크기(cpusetsize)를 먼저 의심합니다.
  2. 요청한 CPU 번호가 시스템에 있나. /sys/devices/system/cpu/ 등으로 확인합니다.
  3. 그 스레드의 cgroup은 어디인가. 프로세스 ID가 아니라 대상 스레드의 TID로 봅니다. /proc/<TID>/cgroup(v1이면 /proc/<TID>/cpuset).
  4. 그 cgroup의 cpuset.cpus.effective는 무엇인가. 요청과 하나도 안 겹치면 EINVAL, 일부만 겹치면 조용히 잘린 것입니다.
  5. 실제 결과는 Cpus_allowed_list로 확인. 스레드별로 다를 수 있다는 점을 기억합니다.

핫플러그와 isolcpus

cpuset(7)의 EXCEPTIONS 절에는 반대 방향의 예외도 있습니다. 핫플러그로 어떤 cpuset에 할당된 CPU를 모두 제거하면, 커널은 해당 프로세스들이 모든 CPU를 쓸 수 있도록 cpus_allowed를 갱신합니다. 허용된 CPU가 전부 오프라인이 된 프로세스를 굶기느니 cpuset 배치를 어기는 쪽을 택한다는 설명입니다. 문서는 핫플러그로 CPU를 더하거나 뺄 때 cpuset이 온라인 CPU만 가리키도록 사용자 코드가 다시 설정하라고 권합니다. cpuset(7)은 레거시 cpuset 인터페이스 문서이므로 이 예외를 cgroup v2에 그대로 옮겨 적용할 수는 없습니다. v2 문서는 cpuset.cpus.effective가 핫플러그의 영향을 받는다고만 적습니다.

부팅 옵션 isolcpus로 격리한 CPU에는 프로세스가 스케줄되지 않고, sched_setaffinity(2)에 따르면 그 CPU에 프로세스를 올리는 방법은 sched_setaffinity()나 cpuset 메커니즘뿐입니다. cgroup v2 문서에 따르면 isolcpus로 격리한 CPU를 파티션에 넣으려면 isolated 파티션이어야 합니다.

성능 이야기는 측정한 뒤에

sched_setaffinity(2)는 affinity로 성능상 이점을 얻을 수 있다고 적고, CPU를 바꿀 때 생기는 캐시 무효화 비용을 피할 수 있다는 점을 예로 듭니다. 같은 문서의 예제도 두 프로세스를 같은 CPU, 같은 코어의 다른 CPU, 다른 코어에 둘 때 실행 시간이 캐시 효과와 배치에 따라 달라진다는 것을 보여 줍니다. 하지만 이것이 내 워크로드에서 빨라진다는 보장은 아닙니다. 이 글도 성능 수치를 제시하지 않습니다.

운영 중인 서비스의 affinity나 cpuset을 바꾸기 전에는 지금 실제로 허용된 범위부터 확인하고, 바꾼 뒤에 무엇이 달라졌는지를 같은 조건에서 측정하세요. cpuset 범위를 넓히거나 좁히는 일은 같은 cgroup의 다른 프로세스에도 영향을 줍니다.

확인한 범위와 한계

  • 이 글은 Linux man-pages 프로젝트의 git 저장소에 있는 man 페이지 원본(미배포 판)과 docs.kernel.org의 cgroup v2 문서(7.3.0-rc7 빌드)를 2026-10-18에 확인한 내용입니다. 배포판에 설치된 man 페이지나 커널 버전에 따라 내용이 다를 수 있습니다.
  • 문서에 버전이 적힌 항목: Cpus_allowed는 Linux 2.6.24부터, Cpus_allowed_list는 2.6.26부터, cpuset은 2.6.12부터, sched_setaffinity는 Linux 2.5.8과 glibc 2.3부터입니다.
  • cpuset(7)은 /dev/cpuset의 레거시 인터페이스를 설명합니다. cgroup v2의 파일 이름과 동작은 cgroup v2 문서를 기준으로 했습니다.
  • taskset 같은 명령줄 도구의 동작은 이번에 확인한 자료에 없어서 다루지 않았습니다.

출처

2026-10-18에 확인했습니다.

블로그로